aboutsummaryrefslogtreecommitdiffstats
path: root/crates/turtle/src/command/client/search
diff options
context:
space:
mode:
Diffstat (limited to 'crates/turtle/src/command/client/search')
-rw-r--r--crates/turtle/src/command/client/search/cursor.rs405
-rw-r--r--crates/turtle/src/command/client/search/duration.rs65
-rw-r--r--crates/turtle/src/command/client/search/engines.rs95
-rw-r--r--crates/turtle/src/command/client/search/engines/daemon.rs242
-rw-r--r--crates/turtle/src/command/client/search/engines/db.rs110
-rw-r--r--crates/turtle/src/command/client/search/engines/skim.rs229
-rw-r--r--crates/turtle/src/command/client/search/history_list.rs429
-rw-r--r--crates/turtle/src/command/client/search/inspector.rs421
-rw-r--r--crates/turtle/src/command/client/search/interactive.rs3041
-rw-r--r--crates/turtle/src/command/client/search/keybindings/actions.rs322
-rw-r--r--crates/turtle/src/command/client/search/keybindings/conditions.rs801
-rw-r--r--crates/turtle/src/command/client/search/keybindings/defaults.rs1286
-rw-r--r--crates/turtle/src/command/client/search/keybindings/key.rs629
-rw-r--r--crates/turtle/src/command/client/search/keybindings/keymap.rs233
-rw-r--r--crates/turtle/src/command/client/search/keybindings/mod.rs14
15 files changed, 8322 insertions, 0 deletions
diff --git a/crates/turtle/src/command/client/search/cursor.rs b/crates/turtle/src/command/client/search/cursor.rs
new file mode 100644
index 00000000..84f94082
--- /dev/null
+++ b/crates/turtle/src/command/client/search/cursor.rs
@@ -0,0 +1,405 @@
+use crate::atuin_client::settings::WordJumpMode;
+
+pub struct Cursor {
+ source: String,
+ index: usize,
+}
+
+impl From<String> for Cursor {
+ fn from(source: String) -> Self {
+ Self { source, index: 0 }
+ }
+}
+
+pub struct WordJumper<'a> {
+ word_chars: &'a str,
+ word_jump_mode: WordJumpMode,
+}
+
+impl WordJumper<'_> {
+ fn is_word_boundary(&self, c: char, next_c: char) -> bool {
+ (c.is_whitespace() && !next_c.is_whitespace())
+ || (!c.is_whitespace() && next_c.is_whitespace())
+ || (self.word_chars.contains(c) && !self.word_chars.contains(next_c))
+ || (!self.word_chars.contains(c) && self.word_chars.contains(next_c))
+ }
+
+ fn emacs_get_next_word_pos(&self, source: &str, index: usize) -> usize {
+ let index = (index + 1..source.len().saturating_sub(1))
+ .find(|&i| self.word_chars.contains(source.chars().nth(i).unwrap()))
+ .unwrap_or(source.len());
+ (index + 1..source.len().saturating_sub(1))
+ .find(|&i| !self.word_chars.contains(source.chars().nth(i).unwrap()))
+ .unwrap_or(source.len())
+ }
+
+ fn emacs_get_prev_word_pos(&self, source: &str, index: usize) -> usize {
+ let index = (1..index)
+ .rev()
+ .find(|&i| self.word_chars.contains(source.chars().nth(i).unwrap()))
+ .unwrap_or(0);
+ (1..index)
+ .rev()
+ .find(|&i| !self.word_chars.contains(source.chars().nth(i).unwrap()))
+ .map_or(0, |i| i + 1)
+ }
+
+ fn subl_get_next_word_pos(&self, source: &str, index: usize) -> usize {
+ let index = (index..source.len().saturating_sub(1)).find(|&i| {
+ self.is_word_boundary(
+ source.chars().nth(i).unwrap(),
+ source.chars().nth(i + 1).unwrap(),
+ )
+ });
+ if index.is_none() {
+ return source.len();
+ }
+ (index.unwrap() + 1..source.len())
+ .find(|&i| !source.chars().nth(i).unwrap().is_whitespace())
+ .unwrap_or(source.len())
+ }
+
+ fn subl_get_prev_word_pos(&self, source: &str, index: usize) -> usize {
+ let index = (1..index)
+ .rev()
+ .find(|&i| !source.chars().nth(i).unwrap().is_whitespace());
+ if index.is_none() {
+ return 0;
+ }
+ (1..index.unwrap())
+ .rev()
+ .find(|&i| {
+ self.is_word_boundary(
+ source.chars().nth(i - 1).unwrap(),
+ source.chars().nth(i).unwrap(),
+ )
+ })
+ .unwrap_or(0)
+ }
+
+ fn get_next_word_pos(&self, source: &str, index: usize) -> usize {
+ match self.word_jump_mode {
+ WordJumpMode::Emacs => self.emacs_get_next_word_pos(source, index),
+ WordJumpMode::Subl => self.subl_get_next_word_pos(source, index),
+ }
+ }
+
+ fn get_prev_word_pos(&self, source: &str, index: usize) -> usize {
+ match self.word_jump_mode {
+ WordJumpMode::Emacs => self.emacs_get_prev_word_pos(source, index),
+ WordJumpMode::Subl => self.subl_get_prev_word_pos(source, index),
+ }
+ }
+}
+
+impl Cursor {
+ pub fn as_str(&self) -> &str {
+ self.source.as_str()
+ }
+
+ pub fn into_inner(self) -> String {
+ self.source
+ }
+
+ /// Returns the string before the cursor
+ pub fn substring(&self) -> &str {
+ &self.source[..self.index]
+ }
+
+ /// Returns the currently selected [`char`]
+ pub fn char(&self) -> Option<char> {
+ self.source[self.index..].chars().next()
+ }
+
+ pub fn right(&mut self) {
+ if self.index < self.source.len() {
+ loop {
+ self.index += 1;
+ if self.source.is_char_boundary(self.index) {
+ break;
+ }
+ }
+ }
+ }
+
+ pub fn left(&mut self) -> bool {
+ if self.index > 0 {
+ loop {
+ self.index -= 1;
+ if self.source.is_char_boundary(self.index) {
+ break true;
+ }
+ }
+ } else {
+ false
+ }
+ }
+
+ pub fn next_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) {
+ let word_jumper = WordJumper {
+ word_chars,
+ word_jump_mode,
+ };
+ self.index = word_jumper.get_next_word_pos(&self.source, self.index);
+ }
+
+ pub fn prev_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) {
+ let word_jumper = WordJumper {
+ word_chars,
+ word_jump_mode,
+ };
+ self.index = word_jumper.get_prev_word_pos(&self.source, self.index);
+ }
+
+ /// Move cursor to the end of the current/next word (vim `e` motion).
+ ///
+ /// If cursor is in the middle of a word, moves to the end of that word.
+ /// If cursor is at the end of a word (or on whitespace), moves to the
+ /// end of the next word.
+ pub fn word_end(&mut self, word_chars: &str) {
+ let len = self.source.len();
+ if self.index >= len {
+ return;
+ }
+
+ let chars: Vec<char> = self.source.chars().collect();
+ let mut char_idx = self.source[..self.index].chars().count();
+
+ if char_idx >= chars.len() {
+ return;
+ }
+
+ let current = chars[char_idx];
+
+ // Check if we're at a word boundary (end of current word or on whitespace)
+ let at_word_boundary = current.is_whitespace() || char_idx + 1 >= chars.len() || {
+ let next = chars[char_idx + 1];
+ next.is_whitespace() || (word_chars.contains(current) != word_chars.contains(next))
+ };
+
+ // If at word boundary, advance past it and skip whitespace to find next word
+ if at_word_boundary {
+ char_idx += 1;
+ while char_idx < chars.len() && chars[char_idx].is_whitespace() {
+ char_idx += 1;
+ }
+ }
+
+ // If we've gone past end, go to end of string
+ if char_idx >= chars.len() {
+ self.index = len;
+ return;
+ }
+
+ // Find end of word: advance until next char is whitespace or different word type
+ let in_word_chars = word_chars.contains(chars[char_idx]);
+ while char_idx < chars.len() {
+ let next_idx = char_idx + 1;
+ if next_idx >= chars.len() {
+ // At last char, move past it
+ char_idx = next_idx;
+ break;
+ }
+ let next_c = chars[next_idx];
+ if next_c.is_whitespace() || (word_chars.contains(next_c) != in_word_chars) {
+ // Next char is start of new word/whitespace, so current char is end
+ char_idx = next_idx;
+ break;
+ }
+ char_idx += 1;
+ }
+
+ // Convert char index back to byte index
+ self.index = chars.iter().take(char_idx).map(|c| c.len_utf8()).sum();
+ }
+
+ pub fn insert(&mut self, c: char) {
+ self.source.insert(self.index, c);
+ self.index += c.len_utf8();
+ }
+
+ pub fn remove(&mut self) -> Option<char> {
+ if self.index < self.source.len() {
+ Some(self.source.remove(self.index))
+ } else {
+ None
+ }
+ }
+
+ pub fn remove_next_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) {
+ let word_jumper = WordJumper {
+ word_chars,
+ word_jump_mode,
+ };
+ let next_index = word_jumper.get_next_word_pos(&self.source, self.index);
+ self.source.replace_range(self.index..next_index, "");
+ }
+
+ pub fn remove_prev_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) {
+ let word_jumper = WordJumper {
+ word_chars,
+ word_jump_mode,
+ };
+ let next_index = word_jumper.get_prev_word_pos(&self.source, self.index);
+ self.source.replace_range(next_index..self.index, "");
+ self.index = next_index;
+ }
+
+ pub fn back(&mut self) -> Option<char> {
+ if self.left() { self.remove() } else { None }
+ }
+
+ pub fn clear(&mut self) {
+ self.source.clear();
+ self.index = 0;
+ }
+
+ pub fn clear_to_start(&mut self) {
+ self.source.replace_range(..self.index, "");
+ self.index = 0;
+ }
+
+ pub fn clear_to_end(&mut self) {
+ self.source.replace_range(self.index.., "");
+ self.index = self.source.len();
+ }
+
+ pub fn end(&mut self) {
+ self.index = self.source.len();
+ }
+
+ pub fn start(&mut self) {
+ self.index = 0;
+ }
+
+ pub fn position(&self) -> usize {
+ self.index
+ }
+}
+
+#[cfg(test)]
+mod cursor_tests {
+ use super::Cursor;
+ use super::*;
+
+ static EMACS_WORD_JUMPER: WordJumper = WordJumper {
+ word_chars: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
+ word_jump_mode: WordJumpMode::Emacs,
+ };
+
+ static SUBL_WORD_JUMPER: WordJumper = WordJumper {
+ word_chars: "./\\()\"'-:,.;<>~!@#$%^&*|+=[]{}`~?",
+ word_jump_mode: WordJumpMode::Subl,
+ };
+
+ #[test]
+ fn right() {
+ // ö is 2 bytes
+ let mut c = Cursor::from(String::from("öaöböcödöeöfö"));
+ let indices = [0, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18, 20, 20, 20, 20];
+ for i in indices {
+ assert_eq!(c.index, i);
+ c.right();
+ }
+ }
+
+ #[test]
+ fn left() {
+ // ö is 2 bytes
+ let mut c = Cursor::from(String::from("öaöböcödöeöfö"));
+ c.end();
+ let indices = [20, 18, 17, 15, 14, 12, 11, 9, 8, 6, 5, 3, 2, 0, 0, 0, 0];
+ for i in indices {
+ assert_eq!(c.index, i);
+ c.left();
+ }
+ }
+
+ #[test]
+ fn test_emacs_get_next_word_pos() {
+ let s = String::from(" aaa ((()))bbb ((())) ");
+ let indices = [(0, 6), (3, 6), (7, 18), (19, 30)];
+ for (i_src, i_dest) in indices {
+ assert_eq!(EMACS_WORD_JUMPER.get_next_word_pos(&s, i_src), i_dest);
+ }
+ assert_eq!(EMACS_WORD_JUMPER.get_next_word_pos("", 0), 0);
+ }
+
+ #[test]
+ fn test_emacs_get_prev_word_pos() {
+ let s = String::from(" aaa ((()))bbb ((())) ");
+ let indices = [(30, 15), (29, 15), (15, 3), (3, 0)];
+ for (i_src, i_dest) in indices {
+ assert_eq!(EMACS_WORD_JUMPER.get_prev_word_pos(&s, i_src), i_dest);
+ }
+ assert_eq!(EMACS_WORD_JUMPER.get_prev_word_pos("", 0), 0);
+ }
+
+ #[test]
+ fn test_subl_get_next_word_pos() {
+ let s = String::from(" aaa ((()))bbb ((())) ");
+ let indices = [(0, 3), (1, 3), (3, 9), (9, 15), (15, 21), (21, 30)];
+ for (i_src, i_dest) in indices {
+ assert_eq!(SUBL_WORD_JUMPER.get_next_word_pos(&s, i_src), i_dest);
+ }
+ assert_eq!(SUBL_WORD_JUMPER.get_next_word_pos("", 0), 0);
+ }
+
+ #[test]
+ fn test_subl_get_prev_word_pos() {
+ let s = String::from(" aaa ((()))bbb ((())) ");
+ let indices = [(30, 21), (21, 15), (15, 9), (9, 3), (3, 0)];
+ for (i_src, i_dest) in indices {
+ assert_eq!(SUBL_WORD_JUMPER.get_prev_word_pos(&s, i_src), i_dest);
+ }
+ assert_eq!(SUBL_WORD_JUMPER.get_prev_word_pos("", 0), 0);
+ }
+
+ #[test]
+ fn pop() {
+ let mut s = String::from("öaöböcödöeöfö");
+ let mut c = Cursor::from(s.clone());
+ c.end();
+ while !s.is_empty() {
+ let c1 = s.pop();
+ let c2 = c.back();
+ assert_eq!(c1, c2);
+ assert_eq!(s.as_str(), c.substring());
+ }
+ let c1 = s.pop();
+ let c2 = c.back();
+ assert_eq!(c1, c2);
+ }
+
+ #[test]
+ fn back() {
+ let mut c = Cursor::from(String::from("öaöböcödöeöfö"));
+ // move to ^
+ for _ in 0..4 {
+ c.right();
+ }
+ assert_eq!(c.substring(), "öaöb");
+ assert_eq!(c.back(), Some('b'));
+ assert_eq!(c.back(), Some('ö'));
+ assert_eq!(c.back(), Some('a'));
+ assert_eq!(c.back(), Some('ö'));
+ assert_eq!(c.back(), None);
+ assert_eq!(c.as_str(), "öcödöeöfö");
+ }
+
+ #[test]
+ fn insert() {
+ let mut c = Cursor::from(String::from("öaöböcödöeöfö"));
+ // move to ^
+ for _ in 0..4 {
+ c.right();
+ }
+ assert_eq!(c.substring(), "öaöb");
+ c.insert('ö');
+ c.insert('g');
+ c.insert('ö');
+ c.insert('h');
+ assert_eq!(c.substring(), "öaöbögöh");
+ assert_eq!(c.as_str(), "öaöbögöhöcödöeöfö");
+ }
+}
diff --git a/crates/turtle/src/command/client/search/duration.rs b/crates/turtle/src/command/client/search/duration.rs
new file mode 100644
index 00000000..54856c87
--- /dev/null
+++ b/crates/turtle/src/command/client/search/duration.rs
@@ -0,0 +1,65 @@
+use core::fmt;
+use std::{ops::ControlFlow, time::Duration};
+
+#[expect(clippy::module_name_repetitions)]
+pub fn format_duration_into(dur: Duration, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ fn item(unit: &'static str, value: u64) -> ControlFlow<(&'static str, u64)> {
+ if value > 0 {
+ ControlFlow::Break((unit, value))
+ } else {
+ ControlFlow::Continue(())
+ }
+ }
+
+ // impl taken and modified from
+ // https://github.com/tailhook/humantime/blob/master/src/duration.rs#L295-L331
+ // Copyright (c) 2016 The humantime Developers
+ fn fmt(f: Duration) -> ControlFlow<(&'static str, u64), ()> {
+ let secs = f.as_secs();
+ let nanos = f.subsec_nanos();
+
+ let years = secs / 31_557_600; // 365.25d
+ let year_days = secs % 31_557_600;
+ let months = year_days / 2_630_016; // 30.44d
+ let month_days = year_days % 2_630_016;
+ let days = month_days / 86400;
+ let day_secs = month_days % 86400;
+ let hours = day_secs / 3600;
+ let minutes = day_secs % 3600 / 60;
+ let seconds = day_secs % 60;
+
+ let millis = nanos / 1_000_000;
+ let micros = nanos / 1_000;
+
+ // a difference from our impl than the original is that
+ // we only care about the most-significant segment of the duration.
+ // If the item call returns `Break`, then the `?` will early-return.
+ // This allows for a very consise impl
+ item("y", years)?;
+ item("mo", months)?;
+ item("d", days)?;
+ item("h", hours)?;
+ item("m", minutes)?;
+ item("s", seconds)?;
+ item("ms", u64::from(millis))?;
+ item("us", u64::from(micros))?;
+ item("ns", u64::from(nanos))?;
+ ControlFlow::Continue(())
+ }
+
+ match fmt(dur) {
+ ControlFlow::Break((unit, value)) => write!(f, "{value}{unit}"),
+ ControlFlow::Continue(()) => write!(f, "0s"),
+ }
+}
+
+#[expect(clippy::module_name_repetitions)]
+pub fn format_duration(f: Duration) -> String {
+ struct F(Duration);
+ impl fmt::Display for F {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ format_duration_into(self.0, f)
+ }
+ }
+ F(f).to_string()
+}
diff --git a/crates/turtle/src/command/client/search/engines.rs b/crates/turtle/src/command/client/search/engines.rs
new file mode 100644
index 00000000..0f92b4c7
--- /dev/null
+++ b/crates/turtle/src/command/client/search/engines.rs
@@ -0,0 +1,95 @@
+use async_trait::async_trait;
+use crate::atuin_client::{
+ database::{Context, Database, OptFilters},
+ history::{AUTHOR_FILTER_ALL_USER, History, HistoryId},
+ settings::{FilterMode, SearchMode, Settings},
+};
+use eyre::Result;
+
+use super::cursor::Cursor;
+
+#[cfg(feature = "daemon")]
+pub mod daemon;
+pub mod db;
+pub mod skim;
+
+#[expect(unused)] // settings is only used if daemon feature is enabled
+pub fn engine(search_mode: SearchMode, settings: &Settings) -> Box<dyn SearchEngine> {
+ match search_mode {
+ SearchMode::Skim => Box::new(skim::Search::new()) as Box<_>,
+ #[cfg(feature = "daemon")]
+ SearchMode::DaemonFuzzy => Box::new(daemon::Search::new(settings)) as Box<_>,
+ #[cfg(not(feature = "daemon"))]
+ SearchMode::DaemonFuzzy => {
+ // Fall back to fuzzy mode if daemon feature is not enabled
+ Box::new(db::Search(SearchMode::Fuzzy)) as Box<_>
+ }
+ mode => Box::new(db::Search(mode)) as Box<_>,
+ }
+}
+
+pub struct SearchState {
+ pub input: Cursor,
+ pub filter_mode: FilterMode,
+ pub context: Context,
+ pub custom_context: Option<HistoryId>,
+}
+
+impl SearchState {
+ pub(crate) fn rotate_filter_mode(&mut self, settings: &Settings, offset: isize) {
+ let mut i = settings
+ .search
+ .filters
+ .iter()
+ .position(|&m| m == self.filter_mode)
+ .unwrap_or_default();
+ for _ in 0..settings.search.filters.len() {
+ i = (i.wrapping_add_signed(offset)) % settings.search.filters.len();
+ let mode = settings.search.filters[i];
+ if self.filter_mode_available(mode, settings) {
+ self.filter_mode = mode;
+ break;
+ }
+ }
+ }
+
+ fn filter_mode_available(&self, mode: FilterMode, settings: &Settings) -> bool {
+ match mode {
+ FilterMode::Global | FilterMode::SessionPreload => self.custom_context.is_none(),
+ FilterMode::Workspace => settings.workspaces && self.context.git_root.is_some(),
+ _ => true,
+ }
+ }
+}
+
+#[async_trait]
+pub trait SearchEngine: Send + Sync + 'static {
+ async fn full_query(
+ &mut self,
+ state: &SearchState,
+ db: &mut dyn Database,
+ ) -> Result<Vec<History>>;
+
+ async fn query(&mut self, state: &SearchState, db: &mut dyn Database) -> Result<Vec<History>> {
+ if state.input.as_str().is_empty() {
+ Ok(db
+ .search(
+ SearchMode::FullText,
+ state.filter_mode,
+ &state.context,
+ "",
+ OptFilters {
+ limit: Some(200),
+ authors: vec![AUTHOR_FILTER_ALL_USER.to_string()],
+ ..Default::default()
+ },
+ )
+ .await?
+ .into_iter()
+ .collect::<Vec<_>>())
+ } else {
+ self.full_query(state, db).await
+ }
+ }
+ fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize>;
+}
diff --git a/crates/turtle/src/command/client/search/engines/daemon.rs b/crates/turtle/src/command/client/search/engines/daemon.rs
new file mode 100644
index 00000000..b1299c02
--- /dev/null
+++ b/crates/turtle/src/command/client/search/engines/daemon.rs
@@ -0,0 +1,242 @@
+use crate::atuin_client::{
+ database::{Database, OptFilters},
+ history::{AUTHOR_FILTER_ALL_USER, History},
+ settings::{SearchMode, Settings},
+};
+use crate::atuin_daemon::client::{DaemonClientErrorKind, SearchClient, classify_error};
+use async_trait::async_trait;
+use atuin_nucleo_matcher::{
+ Config, Matcher, Utf32Str,
+ pattern::{CaseMatching, Normalization, Pattern},
+};
+use eyre::Result;
+use tracing::{Level, debug, instrument, span};
+use uuid::Uuid;
+
+use super::{SearchEngine, SearchState};
+use crate::command::client::daemon;
+
+pub struct Search {
+ client: Option<SearchClient>,
+ query_id: u64,
+ settings: Settings,
+ #[cfg(unix)]
+ socket_path: String,
+}
+
+impl Search {
+ pub fn new(settings: &Settings) -> Self {
+ Search {
+ client: None,
+ query_id: 0,
+ settings: settings.clone(),
+ #[cfg(unix)]
+ socket_path: settings.daemon.socket_path.clone(),
+ }
+ }
+
+ #[instrument(skip_all, level = Level::TRACE, name = "get_daemon_client")]
+ async fn get_client(&mut self) -> Result<&mut SearchClient> {
+ if self.client.is_none() {
+ self.connect().await?;
+ }
+ Ok(self.client.as_mut().unwrap())
+ }
+
+ async fn connect(&mut self) -> Result<()> {
+ #[cfg(unix)]
+ let client = SearchClient::new(self.socket_path.clone()).await?;
+
+ self.client = Some(client);
+ Ok(())
+ }
+
+ fn should_retry(err: &eyre::Report) -> bool {
+ matches!(
+ classify_error(err),
+ DaemonClientErrorKind::Connect
+ | DaemonClientErrorKind::Unavailable
+ | DaemonClientErrorKind::Unimplemented
+ )
+ }
+
+ fn next_query_id(&mut self) -> u64 {
+ self.query_id += 1;
+ self.query_id
+ }
+
+ /// Check if query contains regex pattern (r/.../)
+ /// Nucleo doesn't support regex, so we fall back to database search
+ fn contains_regex_pattern(query: &str) -> bool {
+ query.starts_with("r/") || query.contains(" r/")
+ }
+
+ #[instrument(skip_all, level = Level::TRACE, name = "daemon_db_fallback")]
+ async fn fallback_to_db_search(
+ &self,
+ state: &SearchState,
+ db: &dyn Database,
+ ) -> Result<Vec<History>> {
+ let results = db
+ .search(
+ SearchMode::FullText,
+ state.filter_mode,
+ &state.context,
+ state.input.as_str(),
+ OptFilters {
+ limit: Some(200),
+ authors: vec![AUTHOR_FILTER_ALL_USER.to_string()],
+ ..Default::default()
+ },
+ )
+ .await
+ .map_or(Vec::new(), |r| r.into_iter().collect());
+ Ok(results)
+ }
+
+ #[instrument(skip_all, level = Level::TRACE, name = "hydrate_from_db", fields(count = ids.len()))]
+ async fn hydrate_from_db(&self, db: &dyn Database, ids: &[String]) -> Result<Vec<History>> {
+ let placeholders: Vec<String> = ids.iter().map(|id| format!("'{id}'")).collect();
+ let sql_query = format!(
+ "SELECT * FROM history WHERE id IN ({}) ORDER BY timestamp DESC",
+ placeholders.join(",")
+ );
+ Ok(db.query_history(&sql_query).await?)
+ }
+}
+
+#[async_trait]
+impl SearchEngine for Search {
+ #[instrument(skip_all, level = Level::TRACE, name = "daemon_search", fields(query = %state.input.as_str()))]
+ async fn full_query(
+ &mut self,
+ state: &SearchState,
+ db: &mut dyn Database,
+ ) -> Result<Vec<History>> {
+ let query = state.input.as_str().to_string();
+
+ // Fall back to database for regex queries (Nucleo doesn't support regex)
+ if Self::contains_regex_pattern(&query) {
+ debug!(query = %query, "[daemon-client] regex detected, falling back to db");
+ return self.fallback_to_db_search(state, db).await;
+ }
+
+ let query_id = self.next_query_id();
+
+ let span =
+ span!(Level::TRACE, "daemon_search.req_resp", query = %query, query_id = query_id);
+
+ // Try to connect and search; if it fails with a retriable error,
+ // auto-start the daemon and retry once.
+ let first_attempt = async {
+ let client = self.get_client().await?;
+ client
+ .search(
+ query.clone(),
+ query_id,
+ state.filter_mode,
+ Some(state.context.clone()),
+ )
+ .await
+ }
+ .await;
+
+ let mut stream = match first_attempt {
+ Ok(stream) => stream,
+ Err(err) if self.settings.daemon.autostart && Self::should_retry(&err) => {
+ debug!("daemon not available, attempting auto-start");
+ self.client = None;
+
+ daemon::ensure_daemon_running(&self.settings).await?;
+
+ let client = self.get_client().await?;
+ client
+ .search(
+ query.clone(),
+ query_id,
+ state.filter_mode,
+ Some(state.context.clone()),
+ )
+ .await?
+ }
+ Err(err) => return Err(err),
+ };
+
+ let mut ids = Vec::with_capacity(200);
+ span!(Level::TRACE, "daemon_search.resp")
+ .in_scope(async || {
+ while let Ok(Some(response)) = stream.message().await {
+ let span2 = span!(
+ Level::TRACE,
+ "daemon_search.resp.item",
+ query_id = response.query_id
+ );
+ let _span2 = span2.enter();
+ // Only process if the query_id matches (prevents stale responses)
+ if response.query_id == query_id {
+ let uuids = response
+ .ids
+ .iter()
+ .map(|id| {
+ let bytes: [u8; 16] =
+ id.as_slice().try_into().expect("id should be 16 bytes");
+ Uuid::from_bytes(bytes).as_simple().to_string()
+ })
+ .collect::<Vec<_>>();
+ ids.extend(uuids);
+ }
+ drop(_span2);
+ drop(span2);
+ }
+ })
+ .await;
+ drop(span);
+
+ if ids.is_empty() {
+ debug!(query = %query, results = 0, "[daemon-client] empty results");
+ return Ok(Vec::new());
+ }
+
+ // // Hydrate from local database
+ let results = self.hydrate_from_db(db, &ids).await?;
+
+ // // Reorder results to match the order from the daemon (which is ranked by relevance)
+ let ordered_results = span!(Level::TRACE, "reorder_results").in_scope(|| {
+ let mut ordered_results = Vec::with_capacity(results.len());
+ for id in &ids {
+ if let Some(history) = results.iter().find(|h| h.id.0 == *id) {
+ ordered_results.push(history.clone());
+ }
+ }
+ ordered_results
+ });
+
+ debug!(
+ query = %query,
+ results = results.len(),
+ "[daemon-client]"
+ );
+
+ Ok(ordered_results)
+ }
+
+ #[instrument(skip_all, level = Level::TRACE, name = "daemon_highlight")]
+ fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize> {
+ // Use fulltext highlighting for regex queries
+ if Self::contains_regex_pattern(search_input) {
+ return super::db::get_highlight_indices_fulltext(command, search_input);
+ }
+
+ let mut matcher = Matcher::new(Config::DEFAULT);
+ let pattern = Pattern::parse(search_input, CaseMatching::Smart, Normalization::Smart);
+
+ let mut indices: Vec<u32> = Vec::new();
+ let mut haystack_buf = Vec::new();
+
+ let haystack = Utf32Str::new(command, &mut haystack_buf);
+ pattern.indices(haystack, &mut matcher, &mut indices);
+
+ // Convert u32 indices to usize
+ indices.into_iter().map(|i| i as usize).collect()
+ }
+}
diff --git a/crates/turtle/src/command/client/search/engines/db.rs b/crates/turtle/src/command/client/search/engines/db.rs
new file mode 100644
index 00000000..2765faf5
--- /dev/null
+++ b/crates/turtle/src/command/client/search/engines/db.rs
@@ -0,0 +1,110 @@
+use super::{SearchEngine, SearchState};
+use async_trait::async_trait;
+use crate::atuin_client::{
+ database::Database,
+ database::OptFilters,
+ database::{QueryToken, QueryTokenizer},
+ history::{AUTHOR_FILTER_ALL_USER, History},
+ settings::SearchMode,
+};
+use eyre::Result;
+use norm::Metric;
+use norm::fzf::{FzfParser, FzfV2};
+use std::ops::Range;
+use tracing::{Level, instrument};
+
+pub struct Search(pub SearchMode);
+
+#[async_trait]
+impl SearchEngine for Search {
+ #[instrument(skip_all, level = Level::TRACE, name = "db_search", fields(mode = ?self.0, query = %state.input.as_str()))]
+ async fn full_query(
+ &mut self,
+ state: &SearchState,
+ db: &mut dyn Database,
+ ) -> Result<Vec<History>> {
+ let results = db
+ .search(
+ self.0,
+ state.filter_mode,
+ &state.context,
+ state.input.as_str(),
+ OptFilters {
+ limit: Some(200),
+ authors: vec![AUTHOR_FILTER_ALL_USER.to_string()],
+ ..Default::default()
+ },
+ )
+ .await
+ // ignore errors as it may be caused by incomplete regex
+ .map_or(Vec::new(), |r| r.into_iter().collect());
+ Ok(results)
+ }
+
+ #[instrument(skip_all, level = Level::TRACE, name = "db_highlight")]
+ fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize> {
+ if self.0 == SearchMode::Prefix {
+ return vec![];
+ } else if self.0 == SearchMode::FullText {
+ return get_highlight_indices_fulltext(command, search_input);
+ }
+ let mut fzf = FzfV2::new();
+ let mut parser = FzfParser::new();
+ let query = parser.parse(search_input);
+ let mut ranges: Vec<Range<usize>> = Vec::new();
+ let _ = fzf.distance_and_ranges(query, command, &mut ranges);
+
+ // convert ranges to all indices
+ ranges.into_iter().flatten().collect()
+ }
+}
+
+#[instrument(skip_all, level = Level::TRACE, name = "db_highlight_fulltext")]
+pub fn get_highlight_indices_fulltext(command: &str, search_input: &str) -> Vec<usize> {
+ let mut ranges = vec![];
+ let lower_command = command.to_ascii_lowercase();
+
+ for token in QueryTokenizer::new(search_input) {
+ let matchee = if token.has_uppercase() {
+ command
+ } else {
+ &lower_command
+ };
+
+ if token.is_inverse() {
+ continue;
+ }
+
+ match token {
+ QueryToken::Or => {}
+ QueryToken::Regex(r) => {
+ if let Ok(re) = regex::Regex::new(r) {
+ for m in re.find_iter(command) {
+ ranges.push(m.range());
+ }
+ }
+ }
+ QueryToken::MatchStart(term, _) => {
+ if matchee.starts_with(term) {
+ ranges.push(0..term.len());
+ }
+ }
+ QueryToken::MatchEnd(term, _) => {
+ if matchee.ends_with(term) {
+ let l = matchee.len();
+ ranges.push((l - term.len())..l);
+ }
+ }
+ QueryToken::Match(term, _) | QueryToken::MatchFull(term, _) => {
+ for (idx, m) in matchee.match_indices(term) {
+ ranges.push(idx..(idx + m.len()));
+ }
+ }
+ }
+ }
+
+ let mut ret: Vec<_> = ranges.into_iter().flatten().collect();
+ ret.sort_unstable();
+ ret.dedup();
+ ret
+}
diff --git a/crates/turtle/src/command/client/search/engines/skim.rs b/crates/turtle/src/command/client/search/engines/skim.rs
new file mode 100644
index 00000000..96a6574d
--- /dev/null
+++ b/crates/turtle/src/command/client/search/engines/skim.rs
@@ -0,0 +1,229 @@
+use std::path::Path;
+
+use async_trait::async_trait;
+use crate::atuin_client::{
+ database::Database,
+ history::{History, is_known_agent},
+ settings::FilterMode,
+};
+use eyre::Result;
+use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
+use itertools::Itertools;
+use time::OffsetDateTime;
+use tokio::task::yield_now;
+use tracing::{Level, instrument, warn};
+use uuid;
+
+use super::{SearchEngine, SearchState};
+
+pub struct Search {
+ all_history: Vec<(History, i32)>,
+ engine: SkimMatcherV2,
+}
+
+impl Search {
+ pub fn new() -> Self {
+ Search {
+ all_history: vec![],
+ engine: SkimMatcherV2::default(),
+ }
+ }
+}
+
+#[async_trait]
+impl SearchEngine for Search {
+ #[instrument(skip_all, level = Level::TRACE, name = "skim_search", fields(query = %state.input.as_str()))]
+ async fn full_query(
+ &mut self,
+ state: &SearchState,
+ db: &mut dyn Database,
+ ) -> Result<Vec<History>> {
+ if self.all_history.is_empty() {
+ self.all_history = load_all_history(db).await;
+ }
+
+ Ok(fuzzy_search(&self.engine, state, &self.all_history).await)
+ }
+
+ #[instrument(skip_all, level = Level::TRACE, name = "skim_highlight")]
+ fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize> {
+ let (_, indices) = self
+ .engine
+ .fuzzy_indices(command, search_input)
+ .unwrap_or_default();
+ indices
+ }
+}
+
+#[instrument(skip_all, level = Level::TRACE, name = "load_all_history")]
+async fn load_all_history(db: &dyn Database) -> Vec<(History, i32)> {
+ db.all_with_count().await.unwrap()
+}
+
+#[expect(clippy::too_many_lines)]
+#[instrument(skip_all, level = Level::TRACE, name = "fuzzy_match", fields(history_count = all_history.len()))]
+async fn fuzzy_search(
+ engine: &SkimMatcherV2,
+ state: &SearchState,
+ all_history: &[(History, i32)],
+) -> Vec<History> {
+ let mut set = Vec::with_capacity(200);
+ let mut ranks = Vec::with_capacity(200);
+ let query = state.input.as_str();
+ let now = OffsetDateTime::now_utc();
+
+ for (i, (history, count)) in all_history.iter().enumerate() {
+ if i % 256 == 0 {
+ yield_now().await;
+ }
+ if is_known_agent(&history.author) {
+ continue;
+ }
+ let context = &state.context;
+ let git_root = context
+ .git_root
+ .as_ref()
+ .and_then(|git_root| git_root.to_str())
+ .unwrap_or(&context.cwd);
+ match state.filter_mode {
+ FilterMode::Global => {}
+ // we aggregate host by ',' separating them
+ FilterMode::Host
+ if history
+ .hostname
+ .split(',')
+ .contains(&context.hostname.as_str()) => {}
+ // we aggregate session by concattenating them.
+ // sessions are 32 byte simple uuid formats
+ FilterMode::Session
+ if history
+ .session
+ .as_bytes()
+ .chunks(32)
+ .contains(&context.session.as_bytes()) => {}
+ // SessionPreload: include current session + global history from before session start
+ FilterMode::SessionPreload => {
+ let is_current_session = {
+ history
+ .session
+ .as_bytes()
+ .chunks(32)
+ .any(|chunk| chunk == context.session.as_bytes())
+ };
+
+ if !is_current_session {
+ let Ok(uuid) = uuid::Uuid::parse_str(&context.session) else {
+ warn!("failed to parse session id '{}'", context.session);
+ continue;
+ };
+ let Some(timestamp) = uuid.get_timestamp() else {
+ warn!(
+ "failed to get timestamp from uuid '{}'",
+ uuid.as_hyphenated()
+ );
+ continue;
+ };
+ let (seconds, nanos) = timestamp.to_unix();
+ let Ok(session_start) = time::OffsetDateTime::from_unix_timestamp_nanos(
+ i128::from(seconds) * 1_000_000_000 + i128::from(nanos),
+ ) else {
+ warn!(
+ "failed to create OffsetDateTime from second: {seconds}, nanosecond: {nanos}"
+ );
+ continue;
+ };
+
+ if history.timestamp >= session_start {
+ continue;
+ }
+ }
+ }
+ // we aggregate directory by ':' separating them
+ FilterMode::Directory if history.cwd.split(':').contains(&context.cwd.as_str()) => {}
+ FilterMode::Workspace if history.cwd.split(':').contains(&git_root) => {}
+ _ => continue,
+ }
+ #[expect(clippy::cast_lossless, clippy::cast_precision_loss)]
+ if let Some((score, indices)) = engine.fuzzy_indices(&history.command, query) {
+ let begin = indices.first().copied().unwrap_or_default();
+
+ let mut duration = (now - history.timestamp).as_seconds_f64().log2();
+ if !duration.is_finite() || duration <= 1.0 {
+ duration = 1.0;
+ }
+ // these + X.0 just make the log result a bit smoother.
+ // log is very spiky towards 1-4, but I want a gradual decay.
+ // eg:
+ // log2(4) = 2, log2(5) = 2.3 (16% increase)
+ // log2(8) = 3, log2(9) = 3.16 (5% increase)
+ // log2(16) = 4, log2(17) = 4.08 (2% increase)
+ let count = (*count as f64 + 8.0).log2();
+ let begin = (begin as f64 + 16.0).log2();
+ let path = path_dist(history.cwd.as_ref(), state.context.cwd.as_ref());
+ let path = (path as f64 + 8.0).log2();
+
+ // reduce longer durations, raise higher counts, raise matches close to the start
+ let score = (-score as f64) * count / path / duration / begin;
+
+ 'insert: {
+ // algorithm:
+ // 1. find either the position that this command ranks
+ // 2. find the same command positioned better than our rank.
+ for i in 0..set.len() {
+ // do we out score the current position?
+ if ranks[i] > score {
+ ranks.insert(i, score);
+ set.insert(i, history.clone());
+ let mut j = i + 1;
+ while j < set.len() {
+ // remove duplicates that have a worse score
+ if set[j].command == history.command {
+ ranks.remove(j);
+ set.remove(j);
+
+ // break this while loop because there won't be any other
+ // duplicates.
+ break;
+ }
+ j += 1;
+ }
+
+ // keep it limited
+ if ranks.len() > 200 {
+ ranks.pop();
+ set.pop();
+ }
+
+ break 'insert;
+ }
+ // don't continue if this command has a better score already
+ if set[i].command == history.command {
+ break 'insert;
+ }
+ }
+
+ if set.len() < 200 {
+ ranks.push(score);
+ set.push(history.clone());
+ }
+ }
+ }
+ }
+
+ set
+}
+
+fn path_dist(a: &Path, b: &Path) -> usize {
+ let mut a: Vec<_> = a.components().collect();
+ let b: Vec<_> = b.components().collect();
+
+ let mut dist = 0;
+
+ // pop a until there's a common ancestor
+ while !b.starts_with(&a) {
+ dist += 1;
+ a.pop();
+ }
+
+ b.len() - a.len() + dist
+}
diff --git a/crates/turtle/src/command/client/search/history_list.rs b/crates/turtle/src/command/client/search/history_list.rs
new file mode 100644
index 00000000..4c83d7eb
--- /dev/null
+++ b/crates/turtle/src/command/client/search/history_list.rs
@@ -0,0 +1,429 @@
+use std::time::Duration;
+
+use super::duration::format_duration;
+use super::engines::SearchEngine;
+use crate::atuin_client::{
+ history::History,
+ settings::{UiColumn, UiColumnType},
+ theme::{Meaning, Theme},
+};
+use crate::atuin_common::utils::Escapable as _;
+use itertools::Itertools;
+use ratatui::{
+ backend::FromCrossterm,
+ buffer::Buffer,
+ crossterm::style,
+ layout::Rect,
+ style::{Modifier, Style},
+ widgets::{Block, StatefulWidget, Widget},
+};
+use time::OffsetDateTime;
+
+pub struct HistoryHighlighter<'a> {
+ pub engine: &'a dyn SearchEngine,
+ pub search_input: &'a str,
+}
+
+impl HistoryHighlighter<'_> {
+ pub fn get_highlight_indices(&self, command: &str) -> Vec<usize> {
+ self.engine
+ .get_highlight_indices(command, self.search_input)
+ }
+}
+
+pub struct HistoryList<'a> {
+ history: &'a [History],
+ block: Option<Block<'a>>,
+ inverted: bool,
+ /// Apply an alternative highlighting to the selected row
+ alternate_highlight: bool,
+ now: &'a dyn Fn() -> OffsetDateTime,
+ indicator: &'a str,
+ theme: &'a Theme,
+ history_highlighter: HistoryHighlighter<'a>,
+ show_numeric_shortcuts: bool,
+ /// Columns to display (in order, after the indicator)
+ columns: &'a [UiColumn],
+}
+
+#[derive(Default)]
+pub struct ListState {
+ offset: usize,
+ selected: usize,
+ max_entries: usize,
+}
+
+impl ListState {
+ pub fn selected(&self) -> usize {
+ self.selected
+ }
+
+ pub fn max_entries(&self) -> usize {
+ self.max_entries
+ }
+
+ pub fn offset(&self) -> usize {
+ self.offset
+ }
+
+ pub fn select(&mut self, index: usize) {
+ self.selected = index;
+ }
+}
+
+impl StatefulWidget for HistoryList<'_> {
+ type State = ListState;
+
+ fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
+ let list_area = self.block.take().map_or(area, |b| {
+ let inner_area = b.inner(area);
+ b.render(area, buf);
+ inner_area
+ });
+
+ if list_area.width < 1 || list_area.height < 1 || self.history.is_empty() {
+ return;
+ }
+ let list_height = list_area.height as usize;
+
+ let (start, end) = self.get_items_bounds(state.selected, state.offset, list_height);
+ state.offset = start;
+ state.max_entries = end - start;
+
+ let mut s = DrawState {
+ buf,
+ list_area,
+ x: 0,
+ y: 0,
+ state,
+ inverted: self.inverted,
+ alternate_highlight: self.alternate_highlight,
+ now: &self.now,
+ indicator: self.indicator,
+ theme: self.theme,
+ history_highlighter: self.history_highlighter,
+ show_numeric_shortcuts: self.show_numeric_shortcuts,
+ columns: self.columns,
+ };
+
+ for item in self.history.iter().skip(state.offset).take(end - start) {
+ s.render_row(item);
+
+ // reset line
+ s.y += 1;
+ s.x = 0;
+ }
+ }
+}
+
+impl<'a> HistoryList<'a> {
+ #[expect(clippy::too_many_arguments)]
+ pub fn new(
+ history: &'a [History],
+ inverted: bool,
+ alternate_highlight: bool,
+ now: &'a dyn Fn() -> OffsetDateTime,
+ indicator: &'a str,
+ theme: &'a Theme,
+ history_highlighter: HistoryHighlighter<'a>,
+ show_numeric_shortcuts: bool,
+ columns: &'a [UiColumn],
+ ) -> Self {
+ Self {
+ history,
+ block: None,
+ inverted,
+ alternate_highlight,
+ now,
+ indicator,
+ theme,
+ history_highlighter,
+ show_numeric_shortcuts,
+ columns,
+ }
+ }
+
+ pub fn block(mut self, block: Block<'a>) -> Self {
+ self.block = Some(block);
+ self
+ }
+
+ fn get_items_bounds(&self, selected: usize, offset: usize, height: usize) -> (usize, usize) {
+ let offset = offset.min(self.history.len().saturating_sub(1));
+
+ let max_scroll_space = height.min(10).min(self.history.len() - selected);
+ if offset + height < selected + max_scroll_space {
+ let end = selected + max_scroll_space;
+ (end - height, end)
+ } else if selected < offset {
+ (selected, selected + height)
+ } else {
+ (offset, offset + height)
+ }
+ }
+}
+
+struct DrawState<'a> {
+ buf: &'a mut Buffer,
+ list_area: Rect,
+ x: u16,
+ y: u16,
+ state: &'a ListState,
+ inverted: bool,
+ alternate_highlight: bool,
+ now: &'a dyn Fn() -> OffsetDateTime,
+ indicator: &'a str,
+ theme: &'a Theme,
+ history_highlighter: HistoryHighlighter<'a>,
+ show_numeric_shortcuts: bool,
+ columns: &'a [UiColumn],
+}
+
+// these encode the slices of `" > "`, `" {n} "`, or `" "` in a compact form.
+// Yes, this is a hack, but it makes me feel happy
+static SLICES: &str = " > 1 2 3 4 5 6 7 8 9 ";
+
+impl DrawState<'_> {
+ /// Render a complete row for a history item based on configured columns.
+ fn render_row(&mut self, h: &History) {
+ // Always render the indicator first (width 3)
+ self.index();
+
+ // Calculate the width for the expanding column
+ // Fixed columns use their configured width + 1 (trailing space)
+ let indicator_width: u16 = 3;
+ let fixed_width: u16 = self
+ .columns
+ .iter()
+ .filter(|c| !c.expand)
+ .map(|c| c.width + 1)
+ .sum();
+ let expand_width = self
+ .list_area
+ .width
+ .saturating_sub(indicator_width + fixed_width);
+
+ let style = self.theme.as_style(Meaning::Base);
+ // Render each configured column
+ for (idx, column) in self.columns.iter().enumerate() {
+ if idx != 0 {
+ self.draw(" ", Style::from_crossterm(style));
+ }
+ let width = if column.expand {
+ expand_width
+ } else {
+ column.width
+ };
+ match column.column_type {
+ UiColumnType::Duration => self.duration(h, width),
+ UiColumnType::Time => self.time(h, width),
+ UiColumnType::Datetime => self.datetime(h, width),
+ UiColumnType::Directory => self.directory(h, width),
+ UiColumnType::Host => self.host(h, width),
+ UiColumnType::User => self.user(h, width),
+ UiColumnType::Exit => self.exit_code(h, width),
+ UiColumnType::Command => self.command(h),
+ }
+ }
+ }
+
+ fn index(&mut self) {
+ if !self.show_numeric_shortcuts {
+ let i = self.y as usize + self.state.offset;
+ let is_selected = i == self.state.selected();
+ let prompt: &str = if is_selected { self.indicator } else { " " };
+ self.draw(prompt, Style::default());
+ return;
+ }
+
+ // these encode the slices of `" > "`, `" {n} "`, or `" "` in a compact form.
+ // Yes, this is a hack, but it makes me feel happy
+
+ let i = self.y as usize + self.state.offset;
+ let i = i.checked_sub(self.state.selected);
+ let i = i.unwrap_or(10).min(10) * 2;
+ let prompt: &str = if i == 0 {
+ self.indicator
+ } else {
+ &SLICES[i..i + 3]
+ };
+ self.draw(prompt, Style::default());
+ }
+
+ fn duration(&mut self, h: &History, width: u16) {
+ let style = self.theme.as_style(if h.success() {
+ Meaning::AlertInfo
+ } else {
+ Meaning::AlertError
+ });
+ let duration = Duration::from_nanos(u64::try_from(h.duration).unwrap_or(0));
+ let formatted = format_duration(duration);
+ let w = width as usize;
+ // Right-align duration within its column width, plus trailing space
+ let display = format!("{formatted:>w$}");
+ self.draw(&display, Style::from_crossterm(style));
+ }
+
+ fn time(&mut self, h: &History, width: u16) {
+ let style = self.theme.as_style(Meaning::Guidance);
+
+ // Account for the chance that h.timestamp is "in the future"
+ // This would mean that "since" is negative, and the unwrap here
+ // would fail.
+ // If the timestamp would otherwise be in the future, display
+ // the time since as 0.
+ let since = (self.now)() - h.timestamp;
+ let time = format_duration(since.try_into().unwrap_or_default());
+
+ // Format as "Xs ago" right-aligned within column width
+ let w = width as usize;
+ let time_str = format!("{time} ago");
+
+ let display = format!("{time_str:>w$}");
+ self.draw(&display, Style::from_crossterm(style));
+ }
+
+ fn command(&mut self, h: &History) {
+ let mut style = self.theme.as_style(Meaning::Base);
+ let mut row_highlighted = false;
+ if !self.alternate_highlight && (self.y as usize + self.state.offset == self.state.selected)
+ {
+ row_highlighted = true;
+ // if not applying alternative highlighting to the whole row, color the command
+ style = self.theme.as_style(Meaning::AlertError);
+ style.attributes.set(style::Attribute::Bold);
+ }
+
+ let highlight_indices = self.history_highlighter.get_highlight_indices(
+ h.command
+ .escape_control()
+ .split_ascii_whitespace()
+ .join(" ")
+ .as_str(),
+ );
+
+ let mut pos = 0;
+ for section in h.command.escape_control().split_ascii_whitespace() {
+ if pos != 0 {
+ self.draw(" ", Style::from_crossterm(style));
+ }
+ for ch in section.chars() {
+ if self.x > self.list_area.width {
+ // Avoid attempting to draw a command section beyond the width
+ // of the list
+ return;
+ }
+ let mut style = style;
+ if highlight_indices.contains(&pos) {
+ if row_highlighted {
+ // if the row is highlighted bold is not enough as the whole row is bold
+ // change the color too
+ style = self.theme.as_style(Meaning::AlertWarn);
+ }
+ style.attributes.set(style::Attribute::Bold);
+ }
+ let s = ch.to_string();
+ self.draw(&s, Style::from_crossterm(style));
+ pos += s.len();
+ }
+ pos += 1;
+ }
+ }
+
+ /// Render the absolute datetime column (e.g., "2025-01-22 14:35")
+ fn datetime(&mut self, h: &History, width: u16) {
+ let style = self.theme.as_style(Meaning::Annotation);
+ // Format: YYYY-MM-DD HH:MM
+ let formatted = h
+ .timestamp
+ .format(
+ &time::format_description::parse("[year]-[month]-[day] [hour]:[minute]")
+ .expect("valid format"),
+ )
+ .unwrap_or_else(|_| "????-??-?? ??:??".to_string());
+ let w = width as usize;
+ let display = format!("{formatted:w$}");
+ self.draw(&display, Style::from_crossterm(style));
+ }
+
+ /// Render the directory column (working directory, truncated)
+ fn directory(&mut self, h: &History, width: u16) {
+ let style = self.theme.as_style(Meaning::Annotation);
+ let w = width as usize;
+ let cwd = &h.cwd;
+ let char_count = cwd.chars().count();
+ // Truncate from the left with "..." if too long, plus trailing space
+ // Use character count for comparison and skip for UTF-8 safety
+ let display = if char_count > w && w >= 4 {
+ let truncated: String = cwd.chars().skip(char_count - (w - 3)).collect();
+ format!("...{truncated}")
+ } else {
+ format!("{cwd:w$}")
+ };
+ self.draw(&display, Style::from_crossterm(style));
+ }
+
+ /// Render the host column (just the hostname)
+ fn host(&mut self, h: &History, width: u16) {
+ let style = self.theme.as_style(Meaning::Annotation);
+ let w = width as usize;
+ // Database stores hostname as "hostname:username"
+ let host = h.hostname.split(':').next().unwrap_or(&h.hostname);
+ let char_count = host.chars().count();
+ // Use character count for comparison and take for UTF-8 safety
+ let display = if char_count > w && w >= 4 {
+ let truncated: String = host.chars().take(w.saturating_sub(4)).collect();
+ format!("{truncated}...")
+ } else {
+ format!("{host:w$}")
+ };
+ self.draw(&display, Style::from_crossterm(style));
+ }
+
+ /// Render the user column
+ fn user(&mut self, h: &History, width: u16) {
+ let style = self.theme.as_style(Meaning::Annotation);
+ let w = width as usize;
+ // Database stores hostname as "hostname:username"
+ let user = h.hostname.split(':').nth(1).unwrap_or("");
+ let char_count = user.chars().count();
+ // Use character count for comparison and take for UTF-8 safety
+ let display = if char_count > w && w >= 4 {
+ let truncated: String = user.chars().take(w.saturating_sub(4)).collect();
+ format!("{truncated}...")
+ } else {
+ format!("{user:w$}")
+ };
+ self.draw(&display, Style::from_crossterm(style));
+ }
+
+ /// Render the exit code column
+ fn exit_code(&mut self, h: &History, width: u16) {
+ let style = if h.success() {
+ self.theme.as_style(Meaning::AlertInfo)
+ } else {
+ self.theme.as_style(Meaning::AlertError)
+ };
+ let w = width as usize;
+ let display = format!("{:>w$}", h.exit);
+ self.draw(&display, Style::from_crossterm(style));
+ }
+
+ fn draw(&mut self, s: &str, mut style: Style) {
+ let cx = self.list_area.left() + self.x;
+
+ let cy = if self.inverted {
+ self.list_area.top() + self.y
+ } else {
+ self.list_area.bottom() - self.y - 1
+ };
+
+ if self.alternate_highlight && (self.y as usize + self.state.offset == self.state.selected)
+ {
+ style = style.add_modifier(Modifier::REVERSED);
+ }
+
+ let w = (self.list_area.width - self.x) as usize;
+ self.x += self.buf.set_stringn(cx, cy, s, w, style).0 - cx;
+ }
+}
diff --git a/crates/turtle/src/command/client/search/inspector.rs b/crates/turtle/src/command/client/search/inspector.rs
new file mode 100644
index 00000000..1ebc4383
--- /dev/null
+++ b/crates/turtle/src/command/client/search/inspector.rs
@@ -0,0 +1,421 @@
+use std::time::Duration;
+use time::macros::format_description;
+
+use crate::atuin_client::{
+ history::{History, HistoryStats},
+ settings::{Settings, Timezone},
+};
+use ratatui::{
+ Frame,
+ backend::FromCrossterm,
+ layout::Rect,
+ prelude::{Constraint, Direction, Layout},
+ style::Style,
+ text::{Span, Text},
+ widgets::{Bar, BarChart, BarGroup, Block, Borders, Padding, Paragraph, Row, Table},
+};
+
+use super::duration::format_duration;
+
+use super::super::theme::{Meaning, Theme};
+use super::interactive::{Compactness, to_compactness};
+
+#[expect(clippy::cast_sign_loss)]
+fn u64_or_zero(num: i64) -> u64 {
+ if num < 0 { 0 } else { num as u64 }
+}
+
+pub fn draw_commands(
+ f: &mut Frame<'_>,
+ parent: Rect,
+ history: &History,
+ stats: &HistoryStats,
+ compact: bool,
+ theme: &Theme,
+) {
+ let commands = Layout::default()
+ .direction(if compact {
+ Direction::Vertical
+ } else {
+ Direction::Horizontal
+ })
+ .constraints(if compact {
+ [
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Min(0),
+ ]
+ } else {
+ [
+ Constraint::Ratio(1, 4),
+ Constraint::Ratio(1, 2),
+ Constraint::Ratio(1, 4),
+ ]
+ })
+ .split(parent);
+
+ let command = Paragraph::new(Text::from(Span::styled(
+ history.command.clone(),
+ Style::from_crossterm(theme.as_style(Meaning::Important)),
+ )))
+ .block(if compact {
+ Block::new()
+ .borders(Borders::NONE)
+ .style(Style::from_crossterm(theme.as_style(Meaning::Base)))
+ } else {
+ Block::new()
+ .borders(Borders::ALL)
+ .style(Style::from_crossterm(theme.as_style(Meaning::Base)))
+ .title("Command")
+ .padding(Padding::horizontal(1))
+ });
+
+ let previous = Paragraph::new(
+ stats
+ .previous
+ .clone()
+ .map_or_else(|| "[No previous command]".to_string(), |prev| prev.command),
+ )
+ .block(if compact {
+ Block::new()
+ .borders(Borders::NONE)
+ .style(Style::from_crossterm(theme.as_style(Meaning::Annotation)))
+ } else {
+ Block::new()
+ .borders(Borders::ALL)
+ .style(Style::from_crossterm(theme.as_style(Meaning::Annotation)))
+ .title("Previous command")
+ .padding(Padding::horizontal(1))
+ });
+
+ // Add [] around blank text, as when this is shown in a list
+ // compacted, it makes it more obviously control text.
+ let next = Paragraph::new(
+ stats
+ .next
+ .clone()
+ .map_or_else(|| "[No next command]".to_string(), |next| next.command),
+ )
+ .block(if compact {
+ Block::new()
+ .borders(Borders::NONE)
+ .style(Style::from_crossterm(theme.as_style(Meaning::Annotation)))
+ } else {
+ Block::new()
+ .borders(Borders::ALL)
+ .title("Next command")
+ .padding(Padding::horizontal(1))
+ .style(Style::from_crossterm(theme.as_style(Meaning::Annotation)))
+ });
+
+ f.render_widget(previous, commands[0]);
+ f.render_widget(command, commands[1]);
+ f.render_widget(next, commands[2]);
+}
+
+pub fn draw_stats_table(
+ f: &mut Frame<'_>,
+ parent: Rect,
+ history: &History,
+ tz: Timezone,
+ stats: &HistoryStats,
+ theme: &Theme,
+) {
+ let duration = Duration::from_nanos(u64_or_zero(history.duration));
+ let avg_duration = Duration::from_nanos(stats.average_duration);
+ let (host, user) = history.hostname.split_once(':').unwrap_or(("", ""));
+
+ let rows = [
+ Row::new(vec!["Host".to_string(), host.to_string()]),
+ Row::new(vec!["User".to_string(), user.to_string()]),
+ Row::new(vec![
+ "Time".to_string(),
+ history.timestamp.to_offset(tz.0).to_string(),
+ ]),
+ Row::new(vec!["Duration".to_string(), format_duration(duration)]),
+ Row::new(vec![
+ "Avg duration".to_string(),
+ format_duration(avg_duration),
+ ]),
+ Row::new(vec!["Exit".to_string(), history.exit.to_string()]),
+ Row::new(vec!["Directory".to_string(), history.cwd.clone()]),
+ Row::new(vec!["Session".to_string(), history.session.clone()]),
+ Row::new(vec!["Total runs".to_string(), stats.total.to_string()]),
+ ];
+
+ let widths = [Constraint::Ratio(1, 5), Constraint::Ratio(4, 5)];
+
+ let table = Table::new(rows, widths).column_spacing(1).block(
+ Block::default()
+ .title("Command stats")
+ .borders(Borders::ALL)
+ .style(Style::from_crossterm(theme.as_style(Meaning::Base)))
+ .padding(Padding::vertical(1)),
+ );
+
+ f.render_widget(table, parent);
+}
+
+fn num_to_day(num: &str) -> String {
+ match num {
+ "0" => "Sunday".to_string(),
+ "1" => "Monday".to_string(),
+ "2" => "Tuesday".to_string(),
+ "3" => "Wednesday".to_string(),
+ "4" => "Thursday".to_string(),
+ "5" => "Friday".to_string(),
+ "6" => "Saturday".to_string(),
+ _ => "Invalid day".to_string(),
+ }
+}
+
+fn sort_duration_over_time(durations: &[(String, i64)]) -> Vec<(String, i64)> {
+ let format = format_description!("[day]-[month]-[year]");
+ let output = format_description!("[month]/[year repr:last_two]");
+
+ let mut durations: Vec<(time::Date, i64)> = durations
+ .iter()
+ .map(|d| {
+ (
+ time::Date::parse(d.0.as_str(), &format).expect("invalid date string from sqlite"),
+ d.1,
+ )
+ })
+ .collect();
+
+ durations.sort_by_key(|a| a.0);
+
+ durations
+ .iter()
+ .map(|(date, duration)| {
+ (
+ date.format(output).expect("failed to format sqlite date"),
+ *duration,
+ )
+ })
+ .collect()
+}
+
+fn draw_stats_charts(f: &mut Frame<'_>, parent: Rect, stats: &HistoryStats, theme: &Theme) {
+ let exits: Vec<Bar> = stats
+ .exits
+ .iter()
+ .map(|(exit, count)| {
+ Bar::default()
+ .label(exit.to_string())
+ .value(u64_or_zero(*count))
+ })
+ .collect();
+
+ let exits = BarChart::default()
+ .block(
+ Block::default()
+ .title("Exit code distribution")
+ .style(Style::from_crossterm(theme.as_style(Meaning::Base)))
+ .borders(Borders::ALL),
+ )
+ .bar_width(3)
+ .bar_gap(1)
+ .bar_style(Style::default())
+ .value_style(Style::default())
+ .label_style(Style::default())
+ .data(BarGroup::default().bars(&exits));
+
+ let day_of_week: Vec<Bar> = stats
+ .day_of_week
+ .iter()
+ .map(|(day, count)| {
+ Bar::default()
+ .label(num_to_day(day.as_str()))
+ .value(u64_or_zero(*count))
+ })
+ .collect();
+
+ let day_of_week = BarChart::default()
+ .block(
+ Block::default()
+ .title("Runs per day")
+ .style(Style::from_crossterm(theme.as_style(Meaning::Base)))
+ .borders(Borders::ALL),
+ )
+ .bar_width(3)
+ .bar_gap(1)
+ .bar_style(Style::default())
+ .value_style(Style::default())
+ .label_style(Style::default())
+ .data(BarGroup::default().bars(&day_of_week));
+
+ let duration_over_time = sort_duration_over_time(&stats.duration_over_time);
+ let duration_over_time: Vec<Bar> = duration_over_time
+ .iter()
+ .map(|(date, duration)| {
+ let d = Duration::from_nanos(u64_or_zero(*duration));
+ Bar::default()
+ .label(date.clone())
+ .value(u64_or_zero(*duration))
+ .text_value(format_duration(d))
+ })
+ .collect();
+
+ let duration_over_time = BarChart::default()
+ .block(
+ Block::default()
+ .title("Duration over time")
+ .style(Style::from_crossterm(theme.as_style(Meaning::Base)))
+ .borders(Borders::ALL),
+ )
+ .bar_width(5)
+ .bar_gap(1)
+ .bar_style(Style::default())
+ .value_style(Style::default())
+ .label_style(Style::default())
+ .data(BarGroup::default().bars(&duration_over_time));
+
+ let layout = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Ratio(1, 3),
+ Constraint::Ratio(1, 3),
+ Constraint::Ratio(1, 3),
+ ])
+ .split(parent);
+
+ f.render_widget(exits, layout[0]);
+ f.render_widget(day_of_week, layout[1]);
+ f.render_widget(duration_over_time, layout[2]);
+}
+
+pub fn draw(
+ f: &mut Frame<'_>,
+ chunk: Rect,
+ history: &History,
+ stats: &HistoryStats,
+ settings: &Settings,
+ theme: &Theme,
+ tz: Timezone,
+) {
+ let compactness = to_compactness(f, settings);
+
+ match compactness {
+ Compactness::Ultracompact => draw_ultracompact(f, chunk, history, stats, theme),
+ _ => draw_full(f, chunk, history, stats, theme, tz),
+ }
+}
+
+pub fn draw_ultracompact(
+ f: &mut Frame<'_>,
+ chunk: Rect,
+ history: &History,
+ stats: &HistoryStats,
+ theme: &Theme,
+) {
+ draw_commands(f, chunk, history, stats, true, theme);
+}
+
+pub fn draw_full(
+ f: &mut Frame<'_>,
+ chunk: Rect,
+ history: &History,
+ stats: &HistoryStats,
+ theme: &Theme,
+ tz: Timezone,
+) {
+ let vert_layout = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([Constraint::Ratio(1, 5), Constraint::Ratio(4, 5)])
+ .split(chunk);
+
+ let stats_layout = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)])
+ .split(vert_layout[1]);
+
+ draw_commands(f, vert_layout[0], history, stats, false, theme);
+ draw_stats_table(f, stats_layout[0], history, tz, stats, theme);
+ draw_stats_charts(f, stats_layout[1], stats, theme);
+}
+
+#[cfg(test)]
+mod tests {
+ use super::draw_ultracompact;
+ use crate::atuin_client::{
+ history::{History, HistoryId, HistoryStats},
+ theme::ThemeManager,
+ };
+ use ratatui::{backend::TestBackend, prelude::*};
+ use time::OffsetDateTime;
+
+ fn mock_history_stats() -> (History, HistoryStats) {
+ let history = History {
+ id: HistoryId::from("test1".to_string()),
+ timestamp: OffsetDateTime::now_utc(),
+ duration: 3,
+ exit: 0,
+ command: "/bin/cmd".to_string(),
+ cwd: "/toot".to_string(),
+ session: "sesh1".to_string(),
+ hostname: "hostn".to_string(),
+ author: "hostn".to_string(),
+ intent: None,
+ deleted_at: None,
+ };
+ let next = History {
+ id: HistoryId::from("test2".to_string()),
+ timestamp: OffsetDateTime::now_utc(),
+ duration: 2,
+ exit: 0,
+ command: "/bin/cmd -os".to_string(),
+ cwd: "/toot".to_string(),
+ session: "sesh1".to_string(),
+ hostname: "hostn".to_string(),
+ author: "hostn".to_string(),
+ intent: None,
+ deleted_at: None,
+ };
+ let prev = History {
+ id: HistoryId::from("test3".to_string()),
+ timestamp: OffsetDateTime::now_utc(),
+ duration: 1,
+ exit: 0,
+ command: "/bin/cmd -a".to_string(),
+ cwd: "/toot".to_string(),
+ session: "sesh1".to_string(),
+ hostname: "hostn".to_string(),
+ author: "hostn".to_string(),
+ intent: None,
+ deleted_at: None,
+ };
+ let stats = HistoryStats {
+ next: Some(next.clone()),
+ previous: Some(prev.clone()),
+ total: 2,
+ average_duration: 3,
+ exits: Vec::new(),
+ day_of_week: Vec::new(),
+ duration_over_time: Vec::new(),
+ };
+ (history, stats)
+ }
+
+ #[test]
+ fn test_output_looks_correct_for_ultracompact() {
+ let backend = TestBackend::new(22, 5);
+ let mut terminal = Terminal::new(backend).expect("Could not create terminal");
+ let chunk = Rect::new(0, 0, 22, 5);
+ let (history, stats) = mock_history_stats();
+ let prev = stats.previous.clone().unwrap();
+ let next = stats.next.clone().unwrap();
+
+ let mut manager = ThemeManager::new(Some(true), Some("".to_string()));
+ let theme = manager.load_theme("(none)", None);
+ let _ = terminal.draw(|f| draw_ultracompact(f, chunk, &history, &stats, &theme));
+ let mut lines = [" "; 5].map(|l| Line::from(l));
+ for (n, entry) in [prev, history, next].iter().enumerate() {
+ let mut l = lines[n].to_string();
+ l.replace_range(0..entry.command.len(), &entry.command);
+ lines[n] = Line::from(l);
+ }
+
+ terminal.backend().assert_buffer_lines(lines);
+ }
+}
diff --git a/crates/turtle/src/command/client/search/interactive.rs b/crates/turtle/src/command/client/search/interactive.rs
new file mode 100644
index 00000000..a3d2cb79
--- /dev/null
+++ b/crates/turtle/src/command/client/search/interactive.rs
@@ -0,0 +1,3041 @@
+use std::{
+ io::{IsTerminal, Write, stdout},
+ time::Duration,
+};
+
+#[cfg(unix)]
+use std::io::Read as _;
+
+use crate::atuin_common::{shell::Shell, utils::Escapable as _};
+use eyre::Result;
+use time::OffsetDateTime;
+use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
+
+use super::{
+ cursor::Cursor,
+ engines::{SearchEngine, SearchState},
+ history_list::{HistoryList, ListState},
+};
+use crate::atuin_client::{
+ database::{Context, Database, current_context},
+ history::{History, HistoryId, HistoryStats, store::HistoryStore},
+ settings::{
+ CursorStyle, ExitMode, FilterMode, KeymapMode, PreviewStrategy, SearchMode, Settings,
+ UiColumn,
+ },
+};
+
+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};
+
+use ratatui::{
+ Frame, Terminal, TerminalOptions, Viewport,
+ backend::{CrosstermBackend, FromCrossterm},
+ crossterm::{
+ cursor::SetCursorStyle,
+ event::{self, Event, KeyEvent, MouseEvent},
+ execute, queue, terminal,
+ },
+ layout::{Alignment, Constraint, Direction, Layout},
+ prelude::*,
+ style::{Modifier, Style},
+ text::{Line, Span, Text},
+ widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph, Tabs},
+};
+
+#[cfg(not(target_os = "windows"))]
+use ratatui::crossterm::event::{
+ KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
+};
+
+const TAB_TITLES: [&str; 2] = ["Search", "Inspect"];
+
+pub enum InputAction {
+ Accept(usize),
+ AcceptInspecting,
+ Copy(usize),
+ Delete(usize),
+ DeleteAllMatching(usize),
+ ReturnOriginal,
+ ReturnQuery,
+ Continue,
+ Redraw,
+ SwitchContext(Option<usize>),
+}
+
+#[derive(Clone)]
+pub struct InspectingState {
+ current: Option<HistoryId>,
+ next: Option<HistoryId>,
+ previous: Option<HistoryId>,
+}
+
+impl InspectingState {
+ pub fn move_to_previous(&mut self) {
+ let previous = self.previous.clone();
+ self.reset();
+ self.current = previous;
+ }
+
+ pub fn move_to_next(&mut self) {
+ let next = self.next.clone();
+ self.reset();
+ self.current = next;
+ }
+
+ pub fn reset(&mut self) {
+ self.current = None;
+ self.next = None;
+ self.previous = None;
+ }
+}
+
+pub fn to_compactness(f: &Frame, settings: &Settings) -> Compactness {
+ if match settings.style {
+ crate::atuin_client::settings::Style::Auto => f.area().height < 14,
+ crate::atuin_client::settings::Style::Compact => true,
+ crate::atuin_client::settings::Style::Full => false,
+ } {
+ if settings.auto_hide_height != 0 && f.area().height <= settings.auto_hide_height {
+ Compactness::Ultracompact
+ } else {
+ Compactness::Compact
+ }
+ } else {
+ Compactness::Full
+ }
+}
+
+#[expect(clippy::struct_field_names)]
+#[expect(clippy::struct_excessive_bools)]
+pub struct State {
+ history_count: i64,
+ results_state: ListState,
+ switched_search_mode: bool,
+ search_mode: SearchMode,
+ results_len: usize,
+ accept: bool,
+ keymap_mode: KeymapMode,
+ prefix: bool,
+ current_cursor: Option<CursorStyle>,
+ tab_index: usize,
+ pending_vim_key: Option<char>,
+ original_input_empty: bool,
+
+ pub inspecting_state: InspectingState,
+
+ keymaps: KeymapSet,
+ search: SearchState,
+ engine: Box<dyn SearchEngine>,
+ now: Box<dyn Fn() -> OffsetDateTime + Send>,
+}
+
+#[derive(Clone, Copy)]
+pub enum Compactness {
+ Ultracompact,
+ Compact,
+ Full,
+}
+
+#[derive(Clone, Copy)]
+struct StyleState {
+ compactness: Compactness,
+ invert: bool,
+ inner_width: usize,
+}
+
+impl State {
+ async fn query_results(
+ &mut self,
+ db: &mut dyn Database,
+ smart_sort: bool,
+ ) -> Result<Vec<History>> {
+ let results = self.engine.query(&self.search, db).await?;
+
+ self.inspecting_state = InspectingState {
+ current: None,
+ next: None,
+ previous: None,
+ };
+ self.results_state.select(0);
+ self.results_len = results.len();
+
+ if smart_sort {
+ Ok(crate::atuin_history::sort::sort(
+ self.search.input.as_str(),
+ results,
+ ))
+ } else {
+ Ok(results)
+ }
+ }
+
+ fn handle_input(&mut self, settings: &Settings, input: &Event) -> InputAction {
+ match input {
+ Event::Key(k) => self.handle_key_input(settings, k),
+ Event::Mouse(m) => self.handle_mouse_input(*m, settings.invert),
+ Event::Paste(d) => self.handle_paste_input(d),
+ _ => InputAction::Continue,
+ }
+ }
+
+ fn handle_mouse_input(&mut self, input: MouseEvent, inverted: bool) -> InputAction {
+ match (input.kind, inverted) {
+ (event::MouseEventKind::ScrollDown, false)
+ | (event::MouseEventKind::ScrollUp, true) => {
+ self.scroll_down(1);
+ }
+ (event::MouseEventKind::ScrollDown, true)
+ | (event::MouseEventKind::ScrollUp, false) => {
+ self.scroll_up(1);
+ }
+ _ => {}
+ }
+ InputAction::Continue
+ }
+
+ fn handle_paste_input(&mut self, input: &str) -> InputAction {
+ for i in input.chars() {
+ self.search.input.insert(i);
+ }
+ InputAction::Continue
+ }
+
+ fn cast_cursor_style(style: CursorStyle) -> SetCursorStyle {
+ match style {
+ CursorStyle::DefaultUserShape => SetCursorStyle::DefaultUserShape,
+ CursorStyle::BlinkingBlock => SetCursorStyle::BlinkingBlock,
+ CursorStyle::SteadyBlock => SetCursorStyle::SteadyBlock,
+ CursorStyle::BlinkingUnderScore => SetCursorStyle::BlinkingUnderScore,
+ CursorStyle::SteadyUnderScore => SetCursorStyle::SteadyUnderScore,
+ CursorStyle::BlinkingBar => SetCursorStyle::BlinkingBar,
+ CursorStyle::SteadyBar => SetCursorStyle::SteadyBar,
+ }
+ }
+
+ fn set_keymap_cursor(&mut self, settings: &Settings, keymap_name: &str) {
+ let cursor_style = if keymap_name == "__clear__" {
+ None
+ } else {
+ settings.keymap_cursor.get(keymap_name).copied()
+ }
+ .or_else(|| self.current_cursor.map(|_| CursorStyle::DefaultUserShape));
+
+ if cursor_style != self.current_cursor
+ && let Some(style) = cursor_style
+ {
+ self.current_cursor = cursor_style;
+ let _ = execute!(stdout(), Self::cast_cursor_style(style));
+ }
+ }
+
+ pub fn initialize_keymap_cursor(&mut self, settings: &Settings) {
+ match self.keymap_mode {
+ KeymapMode::Emacs => self.set_keymap_cursor(settings, "emacs"),
+ KeymapMode::VimNormal => self.set_keymap_cursor(settings, "vim_normal"),
+ KeymapMode::VimInsert => self.set_keymap_cursor(settings, "vim_insert"),
+ KeymapMode::Auto => {}
+ }
+ }
+
+ pub fn finalize_keymap_cursor(&mut self, settings: &Settings) {
+ match settings.keymap_mode_shell {
+ KeymapMode::Emacs => self.set_keymap_cursor(settings, "emacs"),
+ KeymapMode::VimNormal => self.set_keymap_cursor(settings, "vim_normal"),
+ KeymapMode::VimInsert => self.set_keymap_cursor(settings, "vim_insert"),
+ KeymapMode::Auto => self.set_keymap_cursor(settings, "__clear__"),
+ }
+ }
+
+ fn handle_key_exit(settings: &Settings) -> InputAction {
+ match settings.exit_mode {
+ ExitMode::ReturnOriginal => InputAction::ReturnOriginal,
+ ExitMode::ReturnQuery => InputAction::ReturnQuery,
+ }
+ }
+
+ /// 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,
+ }
+ }
+ }
+
+ /// 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
+ )
+ }
+
+ 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;
+ }
+
+ // Reset switched_search_mode at start of each key event
+ self.switched_search_mode = false;
+
+ // 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,
+ original_input_empty: self.original_input_empty,
+ has_context: self.search.custom_context.is_some(),
+ };
+
+ // Convert KeyEvent to SingleKey
+ let Some(single) = SingleKey::from_event(input) else {
+ return InputAction::Continue;
+ };
+
+ // --- Phase 1: Resolve (take pending key first, then immutable borrows) ---
+
+ // Take pending key before any immutable borrows of self
+ let pending = self.pending_vim_key.take();
+
+ // 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
+ };
+
+ // The if-let/else-if chain here is clearer than map_or_else with nested closures.
+ #[expect(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,
+ )
+ }
+ };
+
+ // --- Phase 2: Apply mutations ---
+ self.pending_vim_key = new_pending;
+
+ // Reset prefix (before execute, so EnterPrefixMode can re-set it)
+ self.prefix = false;
+
+ if let Some(action) = action {
+ self.execute_action(&action, settings)
+ } else {
+ // No action matched. In insert-capable modes, insert the character.
+ if self.is_insert_mode() && !single.ctrl && !single.alt {
+ match single.code {
+ KeyCodeValue::Char(c) => {
+ self.search.input.insert(c);
+ }
+ KeyCodeValue::Space => {
+ self.search.input.insert(' ');
+ }
+ _ => {}
+ }
+ }
+ 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();
+ }
+
+ /// 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.
+ #[expect(clippy::too_many_lines)]
+ pub(crate) fn execute_action(
+ &mut self,
+ action: &super::keybindings::Action,
+ settings: &Settings,
+ ) -> InputAction {
+ use crate::command::client::search::keybindings::Action;
+
+ 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::CursorWordEnd => {
+ self.search.input.word_end(&settings.word_chars);
+ InputAction::Continue
+ }
+ Action::CursorStart => {
+ self.search.input.start();
+ InputAction::Continue
+ }
+ Action::CursorEnd => {
+ self.search.input.end();
+ InputAction::Continue
+ }
+
+ // -- 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
+ }
+ Action::ClearToStart => {
+ self.search.input.clear_to_start();
+ InputAction::Continue
+ }
+ Action::ClearToEnd => {
+ self.search.input.clear_to_end();
+ InputAction::Continue
+ }
+
+ // -- List navigation (invert-aware) --
+ Action::SelectNext => {
+ if settings.invert {
+ self.scroll_up(1);
+ } else {
+ self.scroll_down(1);
+ }
+ InputAction::Continue
+ }
+ Action::SelectPrevious => {
+ if settings.invert {
+ self.scroll_down(1);
+ } else {
+ self.scroll_up(1);
+ }
+ InputAction::Continue
+ }
+ // -- 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);
+ }
+ 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
+ }
+ 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
+ }
+
+ // -- 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
+ }
+ 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
+ }
+ 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
+ }
+ 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
+ }
+ 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
+ }
+
+ // -- Commands --
+ Action::Accept => {
+ if self.tab_index == 1 {
+ return InputAction::AcceptInspecting;
+ }
+ self.accept = true;
+ InputAction::Accept(self.results_state.selected())
+ }
+ Action::AcceptNth(n) => {
+ self.accept = true;
+ InputAction::Accept(self.results_state.selected() + *n as usize)
+ }
+ Action::ReturnSelection => {
+ if self.tab_index == 1 {
+ return InputAction::AcceptInspecting;
+ }
+ InputAction::Accept(self.results_state.selected())
+ }
+ 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::DeleteAll => InputAction::DeleteAllMatching(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
+ }
+ Action::CycleSearchMode => {
+ self.switched_search_mode = true;
+ self.search_mode = self.search_mode.next(settings);
+ self.engine = engines::engine(self.search_mode, settings);
+ InputAction::Continue
+ }
+ Action::SwitchContext => {
+ InputAction::SwitchContext(Some(self.results_state.selected()))
+ }
+ Action::ClearContext => InputAction::SwitchContext(None),
+ Action::ToggleTab => {
+ self.tab_index = (self.tab_index + 1) % TAB_TITLES.len();
+ InputAction::Continue
+ }
+
+ // -- Mode changes --
+ Action::VimEnterNormal => {
+ self.set_keymap_cursor(settings, "vim_normal");
+ self.keymap_mode = KeymapMode::VimNormal;
+ InputAction::Continue
+ }
+ Action::VimEnterInsert => {
+ self.set_keymap_cursor(settings, "vim_insert");
+ self.keymap_mode = KeymapMode::VimInsert;
+ InputAction::Continue
+ }
+ Action::VimEnterInsertAfter => {
+ self.search.input.right();
+ self.set_keymap_cursor(settings, "vim_insert");
+ self.keymap_mode = KeymapMode::VimInsert;
+ InputAction::Continue
+ }
+ Action::VimEnterInsertAtStart => {
+ self.search.input.start();
+ self.set_keymap_cursor(settings, "vim_insert");
+ self.keymap_mode = KeymapMode::VimInsert;
+ InputAction::Continue
+ }
+ Action::VimEnterInsertAtEnd => {
+ self.search.input.end();
+ self.set_keymap_cursor(settings, "vim_insert");
+ self.keymap_mode = KeymapMode::VimInsert;
+ InputAction::Continue
+ }
+ Action::VimSearchInsert => {
+ self.search.input.clear();
+ self.set_keymap_cursor(settings, "vim_insert");
+ self.keymap_mode = KeymapMode::VimInsert;
+ InputAction::Continue
+ }
+ Action::VimChangeToEnd => {
+ self.search.input.clear_to_end();
+ self.set_keymap_cursor(settings, "vim_insert");
+ self.keymap_mode = KeymapMode::VimInsert;
+ InputAction::Continue
+ }
+ Action::EnterPrefixMode => {
+ self.prefix = true;
+ InputAction::Continue
+ }
+
+ // -- Inspector --
+ Action::InspectPrevious => {
+ self.inspecting_state.move_to_previous();
+ InputAction::Redraw
+ }
+ Action::InspectNext => {
+ self.inspecting_state.move_to_next();
+ InputAction::Redraw
+ }
+
+ // -- Special --
+ Action::Noop => InputAction::Continue,
+ }
+ }
+
+ #[expect(clippy::cast_possible_truncation)]
+ #[expect(clippy::bool_to_int_with_if)]
+ fn calc_preview_height(
+ settings: &Settings,
+ results: &[History],
+ selected: usize,
+ tab_index: usize,
+ compactness: Compactness,
+ border_size: u16,
+ preview_width: u16,
+ ) -> u16 {
+ if settings.show_preview
+ && settings.preview.strategy == PreviewStrategy::Auto
+ && tab_index == 0
+ && !results.is_empty()
+ {
+ let length_current_cmd = results[selected].command.len() as u16;
+ // calculate the number of newlines in the command
+ let num_newlines = results[selected]
+ .command
+ .chars()
+ .filter(|&c| c == '\n')
+ .count() as u16;
+ if num_newlines > 0 {
+ std::cmp::min(
+ settings.max_preview_height,
+ results[selected]
+ .command
+ .split('\n')
+ .map(|line| {
+ (line.len() as u16 + preview_width - 1 - border_size)
+ / (preview_width - border_size)
+ })
+ .sum(),
+ ) + border_size * 2
+ }
+ // The '- 19' takes the characters before the command (duration and time) into account
+ else if length_current_cmd > preview_width - 19 {
+ std::cmp::min(
+ settings.max_preview_height,
+ (length_current_cmd + preview_width - 1 - border_size)
+ / (preview_width - border_size),
+ ) + border_size * 2
+ } else {
+ 1
+ }
+ } else if settings.show_preview
+ && settings.preview.strategy == PreviewStrategy::Static
+ && tab_index == 0
+ {
+ let longest_command = results
+ .iter()
+ .max_by(|h1, h2| h1.command.len().cmp(&h2.command.len()));
+ longest_command.map_or(0, |v| {
+ std::cmp::min(
+ settings.max_preview_height,
+ v.command
+ .split('\n')
+ .map(|line| {
+ (line.len() as u16 + preview_width - 1 - border_size)
+ / (preview_width - border_size)
+ })
+ .sum(),
+ )
+ }) + border_size * 2
+ } else if settings.show_preview && settings.preview.strategy == PreviewStrategy::Fixed {
+ settings.max_preview_height + border_size * 2
+ } else if !matches!(compactness, Compactness::Full) || tab_index == 1 {
+ 0
+ } else {
+ 1
+ }
+ }
+
+ #[expect(clippy::bool_to_int_with_if)]
+ #[expect(clippy::too_many_lines)]
+ #[expect(clippy::too_many_arguments)]
+ fn draw(
+ &mut self,
+ f: &mut Frame,
+ results: &[History],
+ stats: Option<HistoryStats>,
+ inspecting: Option<&History>,
+ settings: &Settings,
+ theme: &Theme,
+ popup_mode: bool,
+ ) {
+ let area = f.area();
+ if popup_mode {
+ f.render_widget(Clear, area);
+ }
+ self.draw_inner(f, area, results, stats, inspecting, settings, theme);
+ }
+
+ #[expect(clippy::too_many_arguments)]
+ #[expect(clippy::too_many_lines)]
+ #[expect(clippy::bool_to_int_with_if)]
+ fn draw_inner(
+ &mut self,
+ f: &mut Frame,
+ area: Rect,
+ results: &[History],
+ stats: Option<HistoryStats>,
+ inspecting: Option<&History>,
+ settings: &Settings,
+ theme: &Theme,
+ ) {
+ let compactness = to_compactness(f, settings);
+ let invert = settings.invert;
+ let border_size = match compactness {
+ Compactness::Full => 1,
+ _ => 0,
+ };
+ let preview_width = area.width.saturating_sub(2);
+ let preview_height = Self::calc_preview_height(
+ settings,
+ results,
+ self.results_state.selected(),
+ self.tab_index,
+ compactness,
+ border_size,
+ preview_width,
+ );
+ let show_help =
+ settings.show_help && (matches!(compactness, Compactness::Full) || area.height > 1);
+ // This is an OR, as it seems more likely for someone to wish to override
+ // tabs unexpectedly being missed, than unexpectedly present.
+ let show_tabs = settings.show_tabs && !matches!(compactness, Compactness::Ultracompact);
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .margin(0)
+ .horizontal_margin(1)
+ .constraints::<&[Constraint]>(
+ if invert {
+ [
+ Constraint::Length(1 + border_size), // input
+ Constraint::Min(1), // results list
+ Constraint::Length(preview_height), // preview
+ Constraint::Length(if show_tabs { 1 } else { 0 }), // tabs
+ Constraint::Length(if show_help { 1 } else { 0 }), // header (sic)
+ ]
+ } else {
+ match compactness {
+ Compactness::Ultracompact => [
+ Constraint::Length(if show_help { 1 } else { 0 }), // header
+ Constraint::Length(0), // tabs
+ Constraint::Min(1), // results list
+ Constraint::Length(0),
+ Constraint::Length(0),
+ ],
+ _ => [
+ Constraint::Length(if show_help { 1 } else { 0 }), // header
+ Constraint::Length(if show_tabs { 1 } else { 0 }), // tabs
+ Constraint::Min(1), // results list
+ Constraint::Length(1 + border_size), // input
+ Constraint::Length(preview_height), // preview
+ ],
+ }
+ }
+ .as_ref(),
+ )
+ .split(area);
+
+ let input_chunk = if invert { chunks[0] } else { chunks[3] };
+ let results_list_chunk = if invert { chunks[1] } else { chunks[2] };
+ let preview_chunk = if invert { chunks[2] } else { chunks[4] };
+ let tabs_chunk = if invert { chunks[3] } else { chunks[1] };
+ let header_chunk = if invert { chunks[4] } else { chunks[0] };
+
+ // TODO: this should be split so that we have one interactive search container that is
+ // EITHER a search box or an inspector. But I'm not doing that now, way too much atm.
+ // also allocate less 🙈
+ let titles: Vec<_> = TAB_TITLES.iter().copied().map(Line::from).collect();
+
+ if show_tabs {
+ let tabs = Tabs::new(titles)
+ .block(Block::default().borders(Borders::NONE))
+ .select(self.tab_index)
+ .style(Style::default())
+ .highlight_style(Style::from_crossterm(theme.as_style(Meaning::Important)));
+
+ f.render_widget(tabs, tabs_chunk);
+ }
+
+ let style = StyleState {
+ compactness,
+ invert,
+ inner_width: input_chunk.width.into(),
+ };
+
+ let header_chunks = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints::<&[Constraint]>(
+ [
+ Constraint::Ratio(1, 5),
+ Constraint::Ratio(3, 5),
+ Constraint::Ratio(1, 5),
+ ]
+ .as_ref(),
+ )
+ .split(header_chunk);
+
+ let title = Self::build_title(theme);
+ f.render_widget(title, header_chunks[0]);
+
+ let help = self.build_help(settings, theme);
+ f.render_widget(help, header_chunks[1]);
+
+ let stats_tab = self.build_stats(theme);
+ f.render_widget(stats_tab, header_chunks[2]);
+
+ let indicator: String = match compactness {
+ Compactness::Ultracompact => {
+ if self.switched_search_mode {
+ format!("S{}>", self.search_mode.as_str().chars().next().unwrap())
+ } else if self.search.custom_context.is_some() {
+ format!(
+ "C{}>",
+ self.search.filter_mode.as_str().chars().next().unwrap()
+ )
+ } else {
+ format!(
+ "{}> ",
+ self.search.filter_mode.as_str().chars().next().unwrap()
+ )
+ }
+ }
+ _ => " > ".to_string(),
+ };
+
+ match self.tab_index {
+ 0 => {
+ let history_highlighter = HistoryHighlighter {
+ engine: self.engine.as_ref(),
+ search_input: self.search.input.as_str(),
+ };
+ let results_list = Self::build_results_list(
+ style,
+ results,
+ self.keymap_mode,
+ &self.now,
+ indicator.as_str(),
+ theme,
+ history_highlighter,
+ settings.show_numeric_shortcuts,
+ &settings.ui.columns,
+ );
+ f.render_stateful_widget(results_list, results_list_chunk, &mut self.results_state);
+ }
+
+ 1 => {
+ if results.is_empty() {
+ let message = Paragraph::new("Nothing to inspect")
+ .block(
+ Block::new()
+ .title(Line::from(" Info ".to_string()))
+ .title_alignment(Alignment::Center)
+ .borders(Borders::ALL)
+ .padding(Padding::vertical(2)),
+ )
+ .alignment(Alignment::Center);
+ f.render_widget(message, results_list_chunk);
+ } else {
+ let inspecting = match inspecting {
+ Some(inspecting) => inspecting,
+ None => &results[self.results_state.selected()],
+ };
+ super::inspector::draw(
+ f,
+ results_list_chunk,
+ inspecting,
+ &stats.expect("Drawing inspector, but no stats"),
+ settings,
+ theme,
+ settings.timezone,
+ );
+ }
+
+ // HACK: I'm following up with abstracting this into the UI container, with a
+ // sub-widget for search + for inspector
+ let feedback = Paragraph::new(
+ "The inspector is new - please give feedback (good, or bad) at https://forum.atuin.sh",
+ );
+ f.render_widget(feedback, input_chunk);
+
+ return;
+ }
+
+ _ => {
+ panic!("invalid tab index");
+ }
+ }
+
+ if !matches!(compactness, Compactness::Ultracompact) {
+ let preview_width = match compactness {
+ Compactness::Full => preview_width - 2,
+ _ => preview_width,
+ };
+ let preview = self.build_preview(
+ results,
+ compactness,
+ preview_width,
+ preview_chunk.width.into(),
+ theme,
+ );
+ #[expect(clippy::cast_possible_truncation)]
+ let prefix_width = settings
+ .ui
+ .columns
+ .iter()
+ .take_while(|col| !col.expand)
+ .map(|col| col.width + 1)
+ .sum::<u16>()
+ + " > ".len() as u16;
+ #[expect(clippy::cast_possible_truncation)]
+ let min_prefix_width = "[ SRCH: FULLTXT ] ".len() as u16;
+ self.draw_preview(
+ f,
+ style,
+ input_chunk,
+ compactness,
+ preview_chunk,
+ preview,
+ std::cmp::max(prefix_width, min_prefix_width),
+ );
+ }
+ }
+
+ #[expect(clippy::cast_possible_truncation, clippy::too_many_arguments)]
+ fn draw_preview(
+ &self,
+ f: &mut Frame,
+ style: StyleState,
+ input_chunk: Rect,
+ compactness: Compactness,
+ preview_chunk: Rect,
+ preview: Paragraph,
+ prefix_width: u16,
+ ) {
+ let input = self.build_input(style, prefix_width);
+ f.render_widget(input, input_chunk);
+
+ f.render_widget(preview, preview_chunk);
+
+ let extra_width = UnicodeWidthStr::width(self.search.input.substring());
+
+ let cursor_offset = match compactness {
+ Compactness::Full => 1,
+ _ => 0,
+ };
+ f.set_cursor_position((
+ // Put cursor past the end of the input text
+ input_chunk.x + extra_width as u16 + prefix_width + cursor_offset,
+ input_chunk.y + cursor_offset,
+ ));
+ }
+
+ fn build_title(theme: &Theme) -> Paragraph<'_> {
+ let title = {
+ let style: Style = Style::from_crossterm(theme.as_style(Meaning::Base));
+ Paragraph::new(Text::from(Span::styled(
+ format!("Atuin v{VERSION}"),
+ style.add_modifier(Modifier::BOLD),
+ )))
+ };
+ title.alignment(Alignment::Left)
+ }
+
+ #[expect(clippy::unused_self)]
+ fn build_help(&self, settings: &Settings, theme: &Theme) -> Paragraph<'_> {
+ match self.tab_index {
+ // search
+ 0 => Paragraph::new(Text::from(Line::from(vec![
+ Span::styled("<esc>", Style::default().add_modifier(Modifier::BOLD)),
+ Span::raw(": exit"),
+ Span::raw(", "),
+ Span::styled("<tab>", Style::default().add_modifier(Modifier::BOLD)),
+ Span::raw(": edit"),
+ Span::raw(", "),
+ Span::styled("<enter>", Style::default().add_modifier(Modifier::BOLD)),
+ Span::raw(if settings.enter_accept {
+ ": run"
+ } else {
+ ": edit"
+ }),
+ Span::raw(", "),
+ Span::styled("<ctrl-o>", Style::default().add_modifier(Modifier::BOLD)),
+ Span::raw(": inspect"),
+ ]))),
+
+ 1 => Paragraph::new(Text::from(Line::from(vec![
+ Span::styled("<esc>", Style::default().add_modifier(Modifier::BOLD)),
+ Span::raw(": exit"),
+ Span::raw(", "),
+ Span::styled("<ctrl-o>", Style::default().add_modifier(Modifier::BOLD)),
+ Span::raw(": search"),
+ Span::raw(", "),
+ Span::styled("<ctrl-d>", Style::default().add_modifier(Modifier::BOLD)),
+ Span::raw(": delete"),
+ ]))),
+
+ _ => unreachable!("invalid tab index"),
+ }
+ .style(Style::from_crossterm(theme.as_style(Meaning::Annotation)))
+ .alignment(Alignment::Center)
+ }
+
+ fn build_stats(&self, theme: &Theme) -> Paragraph<'_> {
+ Paragraph::new(Text::from(Span::raw(format!(
+ "history count: {}",
+ self.history_count,
+ ))))
+ .style(Style::from_crossterm(theme.as_style(Meaning::Annotation)))
+ .alignment(Alignment::Right)
+ }
+
+ #[expect(clippy::too_many_arguments)]
+ fn build_results_list<'a>(
+ style: StyleState,
+ results: &'a [History],
+ keymap_mode: KeymapMode,
+ now: &'a dyn Fn() -> OffsetDateTime,
+ indicator: &'a str,
+ theme: &'a Theme,
+ history_highlighter: HistoryHighlighter<'a>,
+ show_numeric_shortcuts: bool,
+ columns: &'a [UiColumn],
+ ) -> HistoryList<'a> {
+ let results_list = HistoryList::new(
+ results,
+ style.invert,
+ keymap_mode == KeymapMode::VimNormal,
+ now,
+ indicator,
+ theme,
+ history_highlighter,
+ show_numeric_shortcuts,
+ columns,
+ );
+
+ match style.compactness {
+ Compactness::Full => {
+ if style.invert {
+ results_list.block(
+ Block::default()
+ .borders(Borders::LEFT | Borders::RIGHT)
+ .border_type(BorderType::Rounded)
+ .title(format!("{:─>width$}", "", width = style.inner_width - 2)),
+ )
+ } else {
+ results_list.block(
+ Block::default()
+ .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
+ .border_type(BorderType::Rounded),
+ )
+ }
+ }
+ _ => results_list,
+ }
+ }
+
+ fn build_input(&self, style: StyleState, prefix_width: u16) -> Paragraph<'_> {
+ let (pref, mode) = if self.switched_search_mode {
+ (" SRCH:", self.search_mode.as_str())
+ } else if self.search.custom_context.is_some() {
+ (" CTX:", self.search.filter_mode.as_str())
+ } else {
+ ("", self.search.filter_mode.as_str())
+ };
+ // 3: surrounding "[" "] "
+ let mode_width = usize::from(prefix_width) - pref.len() - 3;
+ // sanity check to ensure we don't exceed the layout limits
+ debug_assert!(mode_width >= mode.len(), "mode name '{mode}' is too long!");
+ let input = format!("[{pref}{mode:^mode_width$}] {}", self.search.input.as_str());
+ let input = Paragraph::new(input);
+ match style.compactness {
+ Compactness::Full => {
+ if style.invert {
+ input.block(
+ Block::default()
+ .borders(Borders::LEFT | Borders::RIGHT | Borders::TOP)
+ .border_type(BorderType::Rounded),
+ )
+ } else {
+ input.block(
+ Block::default()
+ .borders(Borders::LEFT | Borders::RIGHT)
+ .border_type(BorderType::Rounded)
+ .title(format!("{:─>width$}", "", width = style.inner_width - 2)),
+ )
+ }
+ }
+ _ => input,
+ }
+ }
+
+ fn build_preview(
+ &self,
+ results: &[History],
+ compactness: Compactness,
+ preview_width: u16,
+ chunk_width: usize,
+ theme: &Theme,
+ ) -> Paragraph<'_> {
+ let selected = self.results_state.selected();
+ let command = if results.is_empty() {
+ String::new()
+ } else {
+ let s = &results[selected].command;
+ let mut lines = Vec::new();
+ for line in s.split('\n') {
+ let line = line.escape_control();
+ let mut width = 0;
+ let mut start = 0;
+ for (idx, ch) in line.char_indices() {
+ let w = ch.width().unwrap_or(0); // None for control chars which should not happen
+ if width + w > preview_width.into() {
+ lines.push(line[start..idx].to_owned());
+ start = idx;
+ width = w;
+ } else {
+ width += w;
+ }
+ }
+ if width != 0 {
+ lines.push(line[start..].to_owned());
+ }
+ }
+ lines.join("\n")
+ };
+
+ match compactness {
+ Compactness::Full => Paragraph::new(command).block(
+ Block::default()
+ .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
+ .border_type(BorderType::Rounded)
+ .title(format!("{:─>width$}", "", width = chunk_width - 2)),
+ ),
+ _ => Paragraph::new(command)
+ .style(Style::from_crossterm(theme.as_style(Meaning::Annotation))),
+ }
+ }
+}
+
+/// The writer used for terminal output - either stdout or /dev/tty
+enum TerminalWriter {
+ Stdout(std::io::Stdout),
+ #[cfg(unix)]
+ Tty(std::fs::File),
+}
+
+impl TerminalWriter {
+ fn new() -> std::io::Result<Self> {
+ let stdout = stdout();
+ if stdout.is_terminal() {
+ return Ok(TerminalWriter::Stdout(stdout));
+ }
+
+ // If stdout is not a terminal (e.g., captured by command substitution),
+ // fall back to /dev/tty so the TUI can still render.
+ // This allows usage like: VAR=$(atuin search -i)
+ #[cfg(unix)]
+ {
+ Ok(TerminalWriter::Tty(
+ std::fs::File::options()
+ .read(true)
+ .write(true)
+ .open("/dev/tty")?,
+ ))
+ }
+ }
+}
+
+impl Write for TerminalWriter {
+ fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
+ match self {
+ TerminalWriter::Stdout(stdout) => stdout.write(buf),
+ #[cfg(unix)]
+ TerminalWriter::Tty(file) => file.write(buf),
+ }
+ }
+
+ fn flush(&mut self) -> std::io::Result<()> {
+ match self {
+ TerminalWriter::Stdout(stdout) => stdout.flush(),
+ #[cfg(unix)]
+ TerminalWriter::Tty(file) => file.flush(),
+ }
+ }
+}
+
+/// Screen state captured from atuin pty-proxy's screen server.
+#[cfg(unix)]
+struct SavedScreen {
+ #[expect(dead_code)]
+ rows: u16,
+ #[expect(dead_code)]
+ cols: u16,
+ cursor_row: u16,
+ cursor_col: u16,
+ /// Pre-formatted ANSI bytes for each screen row, ready to write to stdout.
+ rows_data: Vec<Vec<u8>>,
+}
+
+/// Connect to atuin pty-proxy's Unix socket and fetch the current screen state.
+///
+/// The wire format is:
+/// ```text
+/// [rows: u16 BE][cols: u16 BE][cursor_row: u16 BE][cursor_col: u16 BE]
+/// [row_0_len: u32 BE][row_0_bytes...]
+/// [row_1_len: u32 BE][row_1_bytes...]
+/// ...
+/// ```
+#[cfg(unix)]
+fn fetch_screen_state(socket_path: &str) -> Option<SavedScreen> {
+ use std::os::unix::net::UnixStream;
+
+ let mut stream = UnixStream::connect(socket_path).ok()?;
+ stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?;
+
+ let mut data = Vec::new();
+ stream.read_to_end(&mut data).ok()?;
+
+ if data.len() < 8 {
+ return None;
+ }
+
+ let rows = u16::from_be_bytes([data[0], data[1]]);
+ let cols = u16::from_be_bytes([data[2], data[3]]);
+ let cursor_row = u16::from_be_bytes([data[4], data[5]]);
+ let cursor_col = u16::from_be_bytes([data[6], data[7]]);
+
+ // Parse length-prefixed rows
+ let mut rows_data = Vec::with_capacity(rows as usize);
+ let mut offset = 8;
+ while offset + 4 <= data.len() {
+ let row_len = u32::from_be_bytes([
+ data[offset],
+ data[offset + 1],
+ data[offset + 2],
+ data[offset + 3],
+ ]) as usize;
+ offset += 4;
+ if offset + row_len > data.len() {
+ break;
+ }
+ rows_data.push(data[offset..offset + row_len].to_vec());
+ offset += row_len;
+ }
+
+ Some(SavedScreen {
+ rows,
+ cols,
+ cursor_row,
+ cursor_col,
+ rows_data,
+ })
+}
+
+/// Restore the screen area that was covered by the popup.
+///
+/// Writes the pre-formatted per-row ANSI bytes received from atuin pty-proxy
+/// directly to stdout, which correctly handles wide characters, colors, and
+/// all text attributes without needing a client-side vt100 parser.
+#[cfg(unix)]
+fn restore_popup_area(saved: &SavedScreen, popup_rect: Rect, scroll_offset: u16) {
+ use ratatui::crossterm::cursor::MoveTo;
+
+ let mut stdout = stdout();
+
+ for dy in 0..popup_rect.height {
+ let target_row = popup_rect.y + dy;
+ let source_row = (target_row + scroll_offset) as usize;
+
+ // Clear only the popup region. The server-side rows_formatted() skips
+ // default cells (spaces with default attributes) using cursor jumps, so
+ // any popup content at those positions would remain if not cleared
+ // beforehand. We write `popup_rect.width` spaces instead of
+ // ClearType::CurrentLine so that only the popup area is cleared, not
+ // the entire terminal line.
+ let _ = execute!(
+ stdout,
+ MoveTo(popup_rect.x, target_row),
+ ratatui::crossterm::style::SetAttribute(ratatui::crossterm::style::Attribute::Reset),
+ );
+ let _ = write!(stdout, "{:width$}", "", width = popup_rect.width as usize);
+ let _ = execute!(stdout, MoveTo(popup_rect.x, target_row));
+
+ if let Some(row_bytes) = saved.rows_data.get(source_row) {
+ let _ = stdout.write_all(row_bytes);
+ }
+ }
+
+ let _ = execute!(
+ stdout,
+ MoveTo(
+ saved.cursor_col,
+ saved.cursor_row.saturating_sub(scroll_offset)
+ )
+ );
+ let _ = stdout.flush();
+}
+
+struct Stdout {
+ writer: TerminalWriter,
+ inline_mode: bool,
+ no_mouse: bool,
+}
+
+impl Stdout {
+ pub fn new(inline_mode: bool, no_mouse: bool) -> std::io::Result<Self> {
+ terminal::enable_raw_mode()?;
+
+ let mut writer = TerminalWriter::new()?;
+
+ if !inline_mode {
+ execute!(writer, terminal::EnterAlternateScreen)?;
+ }
+
+ if !no_mouse {
+ execute!(writer, event::EnableMouseCapture)?;
+ }
+
+ execute!(writer, event::EnableBracketedPaste)?;
+
+ #[cfg(not(target_os = "windows"))]
+ execute!(
+ writer,
+ PushKeyboardEnhancementFlags(
+ KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
+ | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES
+ | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
+ ),
+ )?;
+
+ Ok(Self {
+ writer,
+ inline_mode,
+ no_mouse,
+ })
+ }
+}
+
+impl Drop for Stdout {
+ fn drop(&mut self) {
+ #[cfg(not(target_os = "windows"))]
+ if let Err(e) = execute!(self.writer, PopKeyboardEnhancementFlags) {
+ tracing::error!(?e, "Failed to pop keyboard enhancement flags");
+ }
+
+ if !self.inline_mode
+ && let Err(e) = execute!(self.writer, terminal::LeaveAlternateScreen)
+ {
+ tracing::error!(?e, "Failed to leave alt screen mode");
+ }
+
+ if !self.no_mouse
+ && let Err(e) = execute!(self.writer, event::DisableMouseCapture)
+ {
+ tracing::error!(?e, "Failed to disable mouse capture");
+ }
+
+ if let Err(e) = execute!(self.writer, event::DisableBracketedPaste) {
+ tracing::error!(?e, "Failed to disable bracketed paste");
+ }
+
+ if let Err(e) = terminal::disable_raw_mode() {
+ tracing::error!(?e, "Failed to disable raw mode");
+ }
+ }
+}
+
+impl Write for Stdout {
+ fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
+ self.writer.write(buf)
+ }
+
+ fn flush(&mut self) -> std::io::Result<()> {
+ self.writer.flush()
+ }
+}
+
+// this is a big blob of horrible! clean it up!
+/// Compute the popup position and any scroll offset needed to make room.
+///
+/// Given the cursor row, terminal dimensions, and desired popup height,
+/// returns `(popup_rect, scroll_offset)` where `scroll_offset` is the number
+/// of lines the caller should scroll the terminal up before rendering.
+///
+/// This function performs no I/O — it is a pure computation.
+#[cfg(unix)]
+fn compute_popup_placement(
+ cursor_row: u16,
+ term_rows: u16,
+ term_cols: u16,
+ inline_height: u16,
+) -> (Rect, u16) {
+ let popup_w = term_cols;
+ let popup_h = inline_height.min(term_rows);
+ let space_below = term_rows.saturating_sub(cursor_row);
+
+ let (popup_y, scroll) = if popup_h <= space_below {
+ // Fits below cursor
+ (cursor_row, 0u16)
+ } else if cursor_row >= term_rows / 2 {
+ // Bottom half — render above cursor (overlay on existing text)
+ (cursor_row.saturating_sub(popup_h), 0u16)
+ } else {
+ // Top half, not enough space — scroll terminal to make room
+ let scroll = popup_h.saturating_sub(space_below);
+ let popup_y = cursor_row.saturating_sub(scroll);
+ (popup_y, scroll)
+ };
+
+ (Rect::new(0, popup_y, popup_w, popup_h), scroll)
+}
+
+// for now, it works. But it'd be great if it were more easily readable, and
+// modular. I'd like to add some more stats and stuff at some point
+#[expect(
+ clippy::cast_possible_truncation,
+ clippy::too_many_lines,
+ clippy::cognitive_complexity
+)]
+pub async fn history(
+ query: &[String],
+ settings: &Settings,
+ mut db: impl Database,
+ history_store: &HistoryStore,
+ theme: &Theme,
+) -> Result<String> {
+ let inline_height = if settings.shell_up_key_binding {
+ settings
+ .inline_height_shell_up_key_binding
+ .unwrap_or(settings.inline_height)
+ } else {
+ settings.inline_height
+ };
+
+ // Use fullscreen mode if the inline height doesn't fit in the terminal,
+ // this will preserve the scroll position upon exit.
+ // Also force fullscreen when stdout isn't a terminal (e.g., command substitution
+ // like VAR=$(atuin search -i)). In that case, we need to use /dev/tty for the TUI and force
+ // fullscreen mode (inline mode won't work as it requires cursor position queries
+ // that don't work when stdout is captured).
+ let inline_height = if !stdout().is_terminal() {
+ 0
+ } else if let Ok(size) = terminal::size()
+ && inline_height >= size.1
+ {
+ 0
+ } else {
+ inline_height
+ };
+
+ // Popup mode: if running under atuin pty-proxy and inline mode is requested,
+ // fetch the screen state and render as a centered overlay.
+ #[cfg(unix)]
+ let (saved_screen, popup_rect, popup_scroll_offset) = {
+ let socket_path = std::env::var("ATUIN_PTY_PROXY_SOCKET")
+ .or_else(|_| std::env::var("ATUIN_HEX_SOCKET"))
+ .ok();
+ if let Some(ref path) = socket_path
+ && inline_height > 0
+ {
+ let saved = fetch_screen_state(path);
+ if let Some(ref s) = saved {
+ let (term_cols, term_rows) = terminal::size().unwrap_or((s.cols, s.rows));
+ let (popup_rect, scroll) =
+ compute_popup_placement(s.cursor_row, term_rows, term_cols, inline_height);
+
+ // Scroll terminal content up to make room if needed
+ if scroll > 0 {
+ use ratatui::crossterm::cursor::MoveTo;
+ let mut stdout = stdout();
+ let _ = execute!(stdout, MoveTo(0, term_rows - 1));
+ for _ in 0..scroll {
+ let _ = writeln!(stdout);
+ }
+ let _ = stdout.flush();
+ }
+
+ (saved, popup_rect, scroll)
+ } else {
+ (None, Rect::default(), 0u16)
+ }
+ } else {
+ (None, Rect::default(), 0u16)
+ }
+ };
+
+ let popup_mode = saved_screen.is_some();
+
+ let stdout = Stdout::new(inline_height > 0, settings.no_mouse)?;
+
+ // In popup mode, clear the popup region on the physical terminal before
+ // ratatui takes over. Ratatui's diff-based rendering compares against an
+ // initially-empty buffer, so cells that remain "empty" (spaces with default
+ // style) won't be written — leaving underlying terminal text visible.
+ // By pre-clearing with spaces, those cells are already correct on screen.
+ if popup_mode {
+ use ratatui::crossterm::cursor::MoveTo;
+ let mut raw_stdout = std::io::stdout();
+ // Queue all commands without flushing so the terminal receives them
+ // as a single write — no intermediate cursor positions are visible.
+ let _ = queue!(
+ raw_stdout,
+ ratatui::crossterm::style::SetAttribute(ratatui::crossterm::style::Attribute::Reset)
+ );
+ for row in popup_rect.y..popup_rect.y.saturating_add(popup_rect.height) {
+ let _ = queue!(raw_stdout, MoveTo(popup_rect.x, row));
+ let _ = write!(
+ raw_stdout,
+ "{:width$}",
+ "",
+ width = popup_rect.width as usize
+ );
+ }
+ let _ = raw_stdout.flush();
+ }
+
+ let backend = CrosstermBackend::new(stdout);
+ let mut terminal = Terminal::with_options(
+ backend,
+ TerminalOptions {
+ viewport: if popup_mode {
+ Viewport::Fixed(popup_rect)
+ } else if inline_height > 0 {
+ Viewport::Inline(inline_height)
+ } else {
+ Viewport::Fullscreen
+ },
+ },
+ )?;
+
+ let original_query = query.join(" ");
+
+ // Check if this is a command chaining scenario
+ let is_command_chaining = if settings.command_chaining {
+ let trimmed = original_query.trim_end();
+ trimmed.ends_with("&&") || trimmed.ends_with('|')
+ } else {
+ false
+ };
+
+ // For command chaining, start with empty input to allow searching for new commands
+ let search_input = if is_command_chaining {
+ String::new()
+ } else {
+ original_query.clone()
+ };
+
+ let mut input = Cursor::from(search_input);
+ // Put the cursor at the end of the query by default
+ input.end();
+
+ let initial_context = current_context().await?;
+
+ let history_count = db.history_count(false).await?;
+ let search_mode = if settings.shell_up_key_binding {
+ settings
+ .search_mode_shell_up_key_binding
+ .unwrap_or(settings.search_mode)
+ } else {
+ settings.search_mode
+ };
+ let default_filter_mode = settings
+ .filter_mode_shell_up_key_binding
+ .filter(|_| settings.shell_up_key_binding)
+ .unwrap_or_else(|| settings.default_filter_mode(initial_context.git_root.is_some()));
+ let mut app = State {
+ history_count,
+ results_state: ListState::default(),
+ switched_search_mode: false,
+ search_mode,
+ tab_index: 0,
+ inspecting_state: InspectingState {
+ current: None,
+ next: None,
+ previous: None,
+ },
+ keymaps: KeymapSet::from_settings(settings),
+ search: SearchState {
+ input,
+ filter_mode: default_filter_mode,
+ context: initial_context.clone(),
+ custom_context: None,
+ },
+ engine: engines::engine(search_mode, settings),
+ results_len: 0,
+ accept: false,
+ keymap_mode: match settings.keymap_mode {
+ KeymapMode::Auto => KeymapMode::Emacs,
+ value => value,
+ },
+ current_cursor: None,
+ now: if settings.prefers_reduced_motion {
+ let now = OffsetDateTime::now_utc();
+ Box::new(move || now)
+ } else {
+ Box::new(OffsetDateTime::now_utc)
+ },
+ prefix: false,
+ pending_vim_key: None,
+ original_input_empty: original_query.is_empty(),
+ };
+
+ app.initialize_keymap_cursor(settings);
+
+ let mut results = app.query_results(&mut db, settings.smart_sort).await?;
+
+ if inline_height > 0 && !popup_mode {
+ terminal.clear()?;
+ }
+
+ let mut stats: Option<HistoryStats> = None;
+ let mut inspecting: Option<History> = None;
+ let accept;
+ let result = 'render: loop {
+ terminal.draw(|f| {
+ app.draw(
+ f,
+ &results,
+ stats.clone(),
+ inspecting.as_ref(),
+ settings,
+ theme,
+ popup_mode,
+ );
+ })?;
+
+ let initial_input = app.search.input.as_str().to_owned();
+ let initial_filter_mode = app.search.filter_mode;
+ let initial_search_mode = app.search_mode;
+ let initial_custom_context = app.search.custom_context.clone();
+
+ let event_ready = tokio::task::spawn_blocking(|| event::poll(Duration::from_millis(250)));
+
+ tokio::select! {
+ event_ready = event_ready => {
+ if event_ready?? {
+ loop {
+ match app.handle_input(settings, &event::read()?) {
+ InputAction::Continue => {},
+ InputAction::Delete(index) => {
+ if results.is_empty() {
+ break;
+ }
+ app.results_len -= 1;
+ let selected = app.results_state.selected();
+ if selected == app.results_len {
+ app.inspecting_state.reset();
+ app.results_state.select(selected - 1);
+ }
+
+ let entry = results.remove(index);
+
+ let ids = history_store.delete_entries([entry]).await?;
+ history_store.incremental_build(&db, &ids).await?;
+
+ app.tab_index = 0;
+ },
+ InputAction::DeleteAllMatching(index) => {
+ if results.is_empty() {
+ break;
+ }
+
+ let command = results[index].command.clone();
+
+ // Remove matching entries from the visible results
+ results.retain(|e| e.command != command);
+
+ // Query the DB for ALL entries with this command and delete them
+ let all_matching = db.query_history(
+ &format!(
+ "select * from history where command = '{}' and deleted_at is null",
+ command.replace('\'', "''")
+ )
+ ).await?;
+
+ let ids = history_store.delete_entries(all_matching).await?;
+ history_store.incremental_build(&db, &ids).await?;
+
+ app.results_len = results.len();
+ app.results_state = ListState::default();
+ app.inspecting_state.reset();
+ app.tab_index = 0;
+ },
+ InputAction::SwitchContext(index) => {
+ if let Some(index) = index && let Some(entry) = results.get(index) {
+ app.search.custom_context = Some(entry.id.clone());
+ app.search.context = Context::from_history(entry);
+ app.search.filter_mode = FilterMode::Session;
+ app.search.input = Cursor::from(String::new());
+ app.results_state = ListState::default();
+ } else {
+ app.search.custom_context = None;
+ app.search.context = initial_context.clone();
+ app.search.filter_mode = default_filter_mode;
+ }
+ },
+ InputAction::Redraw => {
+ if !popup_mode {
+ terminal.clear()?;
+ }
+ terminal.draw(|f| {
+ app.draw(f, &results, stats.clone(), inspecting.as_ref(), settings, theme, popup_mode);
+ })?;
+ },
+ r => {
+ accept = app.accept;
+ break 'render r;
+ },
+ }
+ if !event::poll(Duration::ZERO)? {
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ if initial_input != app.search.input.as_str()
+ || initial_filter_mode != app.search.filter_mode
+ || initial_search_mode != app.search_mode
+ || initial_custom_context != app.search.custom_context
+ {
+ results = app.query_results(&mut db, settings.smart_sort).await?;
+ }
+
+ // In custom context mode, when no filter is applied, highlight the entry which was used
+ // to enter the context when changing modes. This helps to find your way around.
+ if app.search.custom_context.is_some()
+ && app.search.input.as_str().is_empty()
+ && (initial_custom_context != app.search.custom_context
+ || initial_filter_mode != app.search.filter_mode)
+ && let Some(history_id) = app.search.custom_context.clone()
+ && let Some(pos) = results.iter().position(|entry| entry.id == history_id)
+ {
+ app.results_state.select(pos);
+ }
+
+ let inspecting_id = app.inspecting_state.clone().current;
+ // If inspecting ID is not the current inspecting History, update it.
+ match inspecting_id {
+ Some(inspecting_id) => {
+ if inspecting.is_none() || inspecting_id != inspecting.clone().unwrap().id {
+ inspecting = db.load(inspecting_id.0.as_str()).await?;
+ }
+ }
+ _ => {
+ inspecting = None;
+ }
+ }
+
+ stats = if app.tab_index == 0 {
+ None
+ } else if !results.is_empty() {
+ // If we have stats, then we can indicate next available IDs. This avoids passing
+ // around a database object, or a full stats object.
+ let selected = match inspecting.clone() {
+ Some(insp) => insp,
+ None => results[app.results_state.selected()].clone(),
+ };
+ let stats = db.stats(&selected).await?;
+ app.inspecting_state.current = Some(selected.id);
+ app.inspecting_state.previous = match stats.previous.clone() {
+ Some(p) => Some(p.id),
+ _ => None,
+ };
+ app.inspecting_state.next = match stats.next.clone() {
+ Some(p) => Some(p.id),
+ _ => None,
+ };
+ Some(stats)
+ } else {
+ None
+ };
+ };
+
+ app.finalize_keymap_cursor(settings);
+
+ if popup_mode {
+ // In popup mode, restore the screen area that was covered by the popup.
+ // This must happen before Stdout is dropped (which disables raw mode).
+ #[cfg(unix)]
+ if let Some(ref saved) = saved_screen {
+ restore_popup_area(saved, popup_rect, popup_scroll_offset);
+ }
+ } else if inline_height > 0 {
+ terminal.clear()?;
+ }
+
+ let accept = accept
+ && matches!(
+ Shell::from_env(),
+ Shell::Zsh | Shell::Fish | Shell::Bash | Shell::Xonsh | Shell::Nu | Shell::Powershell
+ );
+
+ let accept_prefix = "__atuin_accept__:";
+
+ match result {
+ InputAction::AcceptInspecting => {
+ match inspecting {
+ Some(result) => {
+ let mut command = result.command;
+
+ if accept {
+ command = String::from(accept_prefix) + &command;
+ }
+
+ // index is in bounds so we return that entry
+ Ok(command)
+ }
+ None => Ok(String::new()),
+ }
+ }
+ InputAction::Accept(index) if index < results.len() => {
+ let mut command = results.swap_remove(index).command;
+
+ if is_command_chaining {
+ command = format!("{} {}", original_query.trim_end(), command);
+ } else if accept {
+ command = String::from(accept_prefix) + &command;
+ }
+
+ // index is in bounds so we return that entry
+ Ok(command)
+ }
+ InputAction::ReturnOriginal => Ok(String::new()),
+ InputAction::Copy(index) => {
+ let cmd = results.swap_remove(index).command;
+ set_clipboard(cmd);
+ Ok(String::new())
+ }
+ InputAction::ReturnQuery | InputAction::Accept(_) => {
+ // Either:
+ // * index == RETURN_QUERY, in which case we should return the input
+ // * out of bounds -> usually implies no selected entry so we return the input
+ Ok(app.search.input.into_inner())
+ }
+ InputAction::Continue
+ | InputAction::Redraw
+ | InputAction::Delete(_)
+ | InputAction::DeleteAllMatching(_)
+ | InputAction::SwitchContext(_) => {
+ unreachable!("should have been handled!")
+ }
+ }
+}
+
+// cli-clipboard only works on Windows, Mac, and Linux.
+
+#[cfg(all(
+ feature = "clipboard",
+ any(target_os = "windows", target_os = "macos", target_os = "linux")
+))]
+fn set_clipboard(s: String) {
+ let mut ctx = arboard::Clipboard::new().unwrap();
+ ctx.set_text(s).unwrap();
+ // Use the clipboard context to make sure it is saved
+ ctx.get_text().unwrap();
+}
+
+#[cfg(not(all(
+ feature = "clipboard",
+ any(target_os = "windows", target_os = "macos", target_os = "linux")
+)))]
+fn set_clipboard(_s: String) {}
+
+#[cfg(test)]
+mod tests {
+ use crate::atuin_client::database::Context;
+ use crate::atuin_client::history::History;
+ use crate::atuin_client::settings::{
+ FilterMode, KeymapMode, Preview, PreviewStrategy, SearchMode, Settings,
+ };
+ use time::OffsetDateTime;
+
+ use crate::command::client::search::engines::{self, SearchState};
+ use crate::command::client::search::history_list::ListState;
+
+ use super::{Compactness, InspectingState, KeymapSet, State};
+
+ #[test]
+ #[expect(clippy::too_many_lines)]
+ fn calc_preview_height_test() {
+ let settings_preview_auto = Settings {
+ preview: Preview {
+ strategy: PreviewStrategy::Auto,
+ },
+ show_preview: true,
+ ..Settings::utc()
+ };
+
+ let settings_preview_auto_h2 = Settings {
+ preview: Preview {
+ strategy: PreviewStrategy::Auto,
+ },
+ show_preview: true,
+ max_preview_height: 2,
+ ..Settings::utc()
+ };
+
+ let settings_preview_h4 = Settings {
+ preview: Preview {
+ strategy: PreviewStrategy::Static,
+ },
+ show_preview: true,
+ max_preview_height: 4,
+ ..Settings::utc()
+ };
+
+ let settings_preview_fixed = Settings {
+ preview: Preview {
+ strategy: PreviewStrategy::Fixed,
+ },
+ show_preview: true,
+ max_preview_height: 15,
+ ..Settings::utc()
+ };
+
+ let cmd_60: History = History::capture()
+ .timestamp(time::OffsetDateTime::now_utc())
+ .command("for i in $(seq -w 10); do echo \"item number $i - abcd\"; done")
+ .cwd("/")
+ .build()
+ .into();
+
+ let cmd_124: History = History::capture()
+ .timestamp(time::OffsetDateTime::now_utc())
+ .command("echo 'Aurea prima sata est aetas, quae vindice nullo, sponte sua, sine lege fidem rectumque colebat. Poena metusque aberant'")
+ .cwd("/")
+ .build()
+ .into();
+
+ let cmd_200: History = History::capture()
+ .timestamp(time::OffsetDateTime::now_utc())
+ .command("CREATE USER atuin WITH ENCRYPTED PASSWORD 'supersecretpassword'; CREATE DATABASE atuin WITH OWNER = atuin; \\c atuin; REVOKE ALL PRIVILEGES ON SCHEMA public FROM PUBLIC; echo 'All done. 200 characters'")
+ .cwd("/")
+ .build()
+ .into();
+
+ let results: Vec<History> = vec![cmd_60, cmd_124, cmd_200];
+
+ // the selected command does not require a preview
+ let no_preview = State::calc_preview_height(
+ &settings_preview_auto,
+ &results,
+ 0_usize,
+ 0_usize,
+ Compactness::Full,
+ 1,
+ 80,
+ );
+ // the selected command requires 2 lines
+ let preview_h2 = State::calc_preview_height(
+ &settings_preview_auto,
+ &results,
+ 1_usize,
+ 0_usize,
+ Compactness::Full,
+ 1,
+ 80,
+ );
+ // the selected command requires 3 lines
+ let preview_h3 = State::calc_preview_height(
+ &settings_preview_auto,
+ &results,
+ 2_usize,
+ 0_usize,
+ Compactness::Full,
+ 1,
+ 80,
+ );
+ // the selected command requires a preview of 1 line (happens when the command is between preview_width-19 and preview_width)
+ let preview_one_line = State::calc_preview_height(
+ &settings_preview_auto,
+ &results,
+ 0_usize,
+ 0_usize,
+ Compactness::Full,
+ 1,
+ 66,
+ );
+ // the selected command requires 3 lines, but we have a max preview height limit of 2
+ let preview_limit_at_2 = State::calc_preview_height(
+ &settings_preview_auto_h2,
+ &results,
+ 2_usize,
+ 0_usize,
+ Compactness::Full,
+ 1,
+ 80,
+ );
+ // the longest command requires 3 lines
+ let preview_static_h3 = State::calc_preview_height(
+ &settings_preview_h4,
+ &results,
+ 1_usize,
+ 0_usize,
+ Compactness::Full,
+ 1,
+ 80,
+ );
+ // the longest command requires 10 lines, but we have a max preview height limit of 4
+ let preview_static_limit_at_4 = State::calc_preview_height(
+ &settings_preview_h4,
+ &results,
+ 1_usize,
+ 0_usize,
+ Compactness::Full,
+ 1,
+ 20,
+ );
+ // the longest command requires 10 lines, but we have a max preview height of 15 and a fixed preview strategy
+ let settings_preview_fixed = State::calc_preview_height(
+ &settings_preview_fixed,
+ &results,
+ 1_usize,
+ 0_usize,
+ Compactness::Full,
+ 1,
+ 20,
+ );
+
+ assert_eq!(no_preview, 1);
+ // 1 * 2 is the space for the border
+ let border_space = 2;
+ assert_eq!(preview_h2, 2 + border_space);
+ assert_eq!(preview_h3, 3 + border_space);
+ assert_eq!(preview_one_line, 1 + border_space);
+ assert_eq!(preview_limit_at_2, 2 + border_space);
+ assert_eq!(preview_static_h3, 3 + border_space);
+ assert_eq!(preview_static_limit_at_4, 4 + border_space);
+ assert_eq!(settings_preview_fixed, 15 + border_space);
+ }
+
+ // 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,
+ results_state: ListState::default(),
+ switched_search_mode: false,
+ search_mode: SearchMode::Fuzzy,
+ results_len: 0,
+ accept: false,
+ keymap_mode: KeymapMode::Auto,
+ prefix: false,
+ current_cursor: None,
+ tab_index: 0,
+ pending_vim_key: None,
+ original_input_empty: false,
+ inspecting_state: InspectingState {
+ current: None,
+ next: None,
+ previous: None,
+ },
+ keymaps: KeymapSet::defaults(&settings),
+ search: SearchState {
+ input: String::new().into(),
+ filter_mode: FilterMode::Directory,
+ context: Context {
+ session: String::new(),
+ cwd: String::new(),
+ hostname: String::new(),
+ host_id: String::new(),
+ git_root: None,
+ },
+ custom_context: None,
+ },
+ engine: engines::engine(SearchMode::Fuzzy, &settings),
+ now: Box::new(OffsetDateTime::now_utc),
+ };
+
+ state.scroll_up(1);
+ state.scroll_down(1);
+ }
+
+ #[test]
+ fn test_accept_keybindings() {
+ use crate::atuin_client::settings::Keys;
+ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+ let mut settings = Settings::utc();
+ settings.keys = Keys {
+ scroll_exits: true,
+ exit_past_line_start: false,
+ accept_past_line_end: true,
+ accept_past_line_start: false,
+ accept_with_backspace: false,
+ prefix: "a".to_string(),
+ };
+
+ let mut state = State {
+ history_count: 1,
+ results_state: ListState::default(),
+ switched_search_mode: false,
+ search_mode: SearchMode::Fuzzy,
+ results_len: 1,
+ accept: false,
+ keymap_mode: KeymapMode::Emacs,
+ prefix: false,
+ current_cursor: None,
+ tab_index: 0,
+ pending_vim_key: None,
+ original_input_empty: false,
+ 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,
+ },
+ custom_context: None,
+ },
+ engine: engines::engine(SearchMode::Fuzzy, &settings),
+ now: Box::new(OffsetDateTime::now_utc),
+ };
+
+ let tab_event = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
+ let result = state.handle_key_input(&settings, &tab_event);
+ assert!(
+ matches!(result, super::InputAction::Accept(_)),
+ "Tab should always accept"
+ );
+
+ // Test left arrow with accept_past_line_start disabled (should continue)
+ let left_event = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
+ let result = state.handle_key_input(&settings, &left_event);
+ assert!(
+ matches!(result, super::InputAction::Continue),
+ "Left arrow should continue when disabled"
+ );
+
+ // 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);
+ assert!(
+ matches!(result, super::InputAction::Continue),
+ "Backspace should continue when disabled"
+ );
+
+ 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(_)),
+ "Backspace should accept at start of line when enabled"
+ );
+
+ state.search.input.insert('t');
+ state.search.input.insert('e');
+ state.search.input.insert('s');
+ state.search.input.insert('t');
+ state.search.input.end();
+
+ let right_event = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
+ let result = state.handle_key_input(&settings, &right_event);
+ assert!(
+ matches!(result, super::InputAction::Accept(_)),
+ "Right arrow should accept at end of line when enabled"
+ );
+
+ 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!(
+ matches!(result, super::InputAction::Continue),
+ "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!(
+ matches!(result, super::InputAction::Continue),
+ "Backspace should continue at end of line, even when enabled"
+ );
+ settings.keys.accept_with_backspace = false;
+ state.keymaps = KeymapSet::defaults(&settings);
+ }
+
+ #[test]
+ fn test_vim_gg_multikey_sequence() {
+ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+ let settings = Settings::utc();
+
+ let mut state = State {
+ history_count: 100,
+ results_state: ListState::default(),
+ switched_search_mode: false,
+ search_mode: SearchMode::Fuzzy,
+ results_len: 100,
+ accept: false,
+ keymap_mode: KeymapMode::VimNormal,
+ prefix: false,
+ current_cursor: None,
+ tab_index: 0,
+ pending_vim_key: None,
+ original_input_empty: false,
+ 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,
+ },
+ custom_context: None,
+ },
+ engine: engines::engine(SearchMode::Fuzzy, &settings),
+ now: Box::new(OffsetDateTime::now_utc),
+ };
+
+ // Start in the middle of the list
+ state.results_state.select(50);
+
+ // First 'g' should set pending state
+ let g_event = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE);
+ let result = state.handle_key_input(&settings, &g_event);
+ assert!(matches!(result, super::InputAction::Continue));
+ assert_eq!(state.pending_vim_key, Some('g'));
+ assert_eq!(state.results_state.selected(), 50); // Position unchanged
+
+ // Second 'g' should jump to end (visual top in non-inverted mode)
+ let result = state.handle_key_input(&settings, &g_event);
+ assert!(matches!(result, super::InputAction::Continue));
+ assert_eq!(state.pending_vim_key, None);
+ assert_eq!(state.results_state.selected(), 99); // Jumped to last index (visual top)
+ }
+
+ #[test]
+ fn test_vim_g_key_clears_on_other_input() {
+ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+ let settings = Settings::utc();
+
+ let mut state = State {
+ history_count: 100,
+ results_state: ListState::default(),
+ switched_search_mode: false,
+ search_mode: SearchMode::Fuzzy,
+ results_len: 100,
+ accept: false,
+ keymap_mode: KeymapMode::VimNormal,
+ prefix: false,
+ current_cursor: None,
+ tab_index: 0,
+ pending_vim_key: None,
+ original_input_empty: false,
+ 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,
+ },
+ custom_context: None,
+ },
+ engine: engines::engine(SearchMode::Fuzzy, &settings),
+ now: Box::new(OffsetDateTime::now_utc),
+ };
+
+ state.results_state.select(50);
+
+ // Press 'g' to set pending state
+ let g_event = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE);
+ state.handle_key_input(&settings, &g_event);
+ assert_eq!(state.pending_vim_key, Some('g'));
+
+ // Press 'j' - should clear pending state
+ let j_event = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
+ state.handle_key_input(&settings, &j_event);
+ assert_eq!(state.pending_vim_key, None);
+ }
+
+ #[test]
+ fn test_vim_big_g_jump_to_bottom() {
+ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+ let settings = Settings::utc();
+
+ let mut state = State {
+ history_count: 100,
+ results_state: ListState::default(),
+ switched_search_mode: false,
+ search_mode: SearchMode::Fuzzy,
+ results_len: 100,
+ accept: false,
+ keymap_mode: KeymapMode::VimNormal,
+ prefix: false,
+ current_cursor: None,
+ tab_index: 0,
+ pending_vim_key: None,
+ original_input_empty: false,
+ 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,
+ },
+ custom_context: None,
+ },
+ engine: engines::engine(SearchMode::Fuzzy, &settings),
+ now: Box::new(OffsetDateTime::now_utc),
+ };
+
+ state.results_state.select(50);
+
+ // 'G' should jump to visual bottom (index 0 in non-inverted mode)
+ let big_g_event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE);
+ let result = state.handle_key_input(&settings, &big_g_event);
+ assert!(matches!(result, super::InputAction::Continue));
+ assert_eq!(state.results_state.selected(), 0);
+ }
+
+ #[test]
+ fn test_vim_ctrl_u_d_half_page_scroll() {
+ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+ let settings = Settings::utc();
+
+ let mut state = State {
+ history_count: 100,
+ results_state: ListState::default(),
+ switched_search_mode: false,
+ search_mode: SearchMode::Fuzzy,
+ results_len: 100,
+ accept: false,
+ keymap_mode: KeymapMode::VimNormal,
+ prefix: false,
+ current_cursor: None,
+ tab_index: 0,
+ pending_vim_key: None,
+ original_input_empty: false,
+ 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,
+ },
+ custom_context: None,
+ },
+ engine: engines::engine(SearchMode::Fuzzy, &settings),
+ now: Box::new(OffsetDateTime::now_utc),
+ };
+
+ state.results_state.select(50);
+
+ // Ctrl+d should return Continue and clear pending key
+ // (scroll amount depends on max_entries which is 0 in tests)
+ state.pending_vim_key = Some('g');
+ let ctrl_d_event = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
+ let result = state.handle_key_input(&settings, &ctrl_d_event);
+ assert!(matches!(result, super::InputAction::Continue));
+ assert_eq!(state.pending_vim_key, None);
+
+ // Ctrl+u should return Continue and clear pending key
+ state.pending_vim_key = Some('g');
+ let ctrl_u_event = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL);
+ let result = state.handle_key_input(&settings, &ctrl_u_event);
+ assert!(matches!(result, super::InputAction::Continue));
+ assert_eq!(state.pending_vim_key, None);
+ }
+
+ #[test]
+ fn test_vim_ctrl_f_b_full_page_scroll() {
+ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+ let settings = Settings::utc();
+
+ let mut state = State {
+ history_count: 100,
+ results_state: ListState::default(),
+ switched_search_mode: false,
+ search_mode: SearchMode::Fuzzy,
+ results_len: 100,
+ accept: false,
+ keymap_mode: KeymapMode::VimNormal,
+ prefix: false,
+ current_cursor: None,
+ tab_index: 0,
+ pending_vim_key: None,
+ original_input_empty: false,
+ 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,
+ },
+ custom_context: None,
+ },
+ engine: engines::engine(SearchMode::Fuzzy, &settings),
+ now: Box::new(OffsetDateTime::now_utc),
+ };
+
+ state.results_state.select(50);
+
+ // Ctrl+f should return Continue and clear pending key
+ // (scroll amount depends on max_entries which is 0 in tests)
+ state.pending_vim_key = Some('g');
+ let ctrl_f_event = KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL);
+ let result = state.handle_key_input(&settings, &ctrl_f_event);
+ assert!(matches!(result, super::InputAction::Continue));
+ assert_eq!(state.pending_vim_key, None);
+
+ // Ctrl+b should return Continue and clear pending key
+ state.pending_vim_key = Some('g');
+ let ctrl_b_event = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL);
+ let result = state.handle_key_input(&settings, &ctrl_b_event);
+ 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,
+ 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,
+ original_input_empty: false,
+ 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,
+ },
+ custom_context: None,
+ },
+ engine: engines::engine(SearchMode::Fuzzy, &settings),
+ 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::atuin_client::settings::ExitMode;
+ use crate::command::client::search::keybindings::Action;
+
+ 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_switch_context() {
+ 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::SwitchContext, &settings);
+ assert!(matches!(result, super::InputAction::SwitchContext(Some(7))));
+ }
+
+ #[test]
+ fn execute_clear_context() {
+ 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::ClearContext, &settings);
+ assert!(matches!(result, super::InputAction::SwitchContext(None)));
+ }
+
+ #[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(), "");
+ }
+
+ #[test]
+ fn keymap_config_return_query() {
+ use crate::atuin_client::settings::KeyBindingConfig;
+ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+ use std::collections::HashMap;
+
+ let mut settings = Settings::utc();
+ // Configure tab to return-query
+ settings.keymap.emacs = HashMap::from([(
+ "tab".to_string(),
+ KeyBindingConfig::Simple("return-query".to_string()),
+ )]);
+
+ let mut state = State {
+ history_count: 100,
+ results_state: ListState::default(),
+ switched_search_mode: false,
+ search_mode: SearchMode::Fuzzy,
+ results_len: 100,
+ accept: false,
+ keymap_mode: KeymapMode::Emacs,
+ prefix: false,
+ current_cursor: None,
+ tab_index: 0,
+ pending_vim_key: None,
+ original_input_empty: false,
+ inspecting_state: InspectingState {
+ current: None,
+ next: None,
+ previous: None,
+ },
+ keymaps: KeymapSet::from_settings(&settings),
+ search: SearchState {
+ input: "test query".to_string().into(),
+ filter_mode: FilterMode::Global,
+ context: Context {
+ session: String::new(),
+ cwd: String::new(),
+ hostname: String::new(),
+ host_id: String::new(),
+ git_root: None,
+ },
+ custom_context: None,
+ },
+ engine: engines::engine(SearchMode::Fuzzy, &settings),
+ now: Box::new(OffsetDateTime::now_utc),
+ };
+
+ let tab_event = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
+ let result = state.handle_key_input(&settings, &tab_event);
+ assert!(
+ matches!(result, super::InputAction::ReturnQuery),
+ "Tab configured as return-query should return InputAction::ReturnQuery"
+ );
+ }
+}
diff --git a/crates/turtle/src/command/client/search/keybindings/actions.rs b/crates/turtle/src/command/client/search/keybindings/actions.rs
new file mode 100644
index 00000000..ff2ef7de
--- /dev/null
+++ b/crates/turtle/src/command/client/search/keybindings/actions.rs
@@ -0,0 +1,322 @@
+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,
+ CursorWordEnd,
+ CursorStart,
+ CursorEnd,
+
+ // Editing
+ DeleteCharBefore,
+ DeleteCharAfter,
+ DeleteWordBefore,
+ DeleteWordAfter,
+ DeleteToWordBoundary,
+ ClearLine,
+ ClearToStart,
+ ClearToEnd,
+
+ // 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,
+ DeleteAll,
+ ReturnOriginal,
+ ReturnQuery,
+ Exit,
+ Redraw,
+ CycleFilterMode,
+ CycleSearchMode,
+ SwitchContext,
+ ClearContext,
+ ToggleTab,
+
+ // Mode changes
+ VimEnterNormal,
+ VimEnterInsert,
+ VimEnterInsertAfter,
+ VimEnterInsertAtStart,
+ VimEnterInsertAtEnd,
+ VimSearchInsert,
+ VimChangeToEnd,
+ 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-word-end" => Ok(Action::CursorWordEnd),
+ "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),
+ "clear-to-start" => Ok(Action::ClearToStart),
+ "clear-to-end" => Ok(Action::ClearToEnd),
+
+ "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),
+ "delete-all" => Ok(Action::DeleteAll),
+ "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),
+ "switch-context" => Ok(Action::SwitchContext),
+ "clear-context" => Ok(Action::ClearContext),
+ "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),
+ "vim-change-to-end" => Ok(Action::VimChangeToEnd),
+ "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::CursorWordEnd => "cursor-word-end".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::ClearToStart => "clear-to-start".to_string(),
+ Action::ClearToEnd => "clear-to-end".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::DeleteAll => "delete-all".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::SwitchContext => "switch-context".to_string(),
+ Action::ClearContext => "clear-context".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::VimChangeToEnd => "vim-change-to-end".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/turtle/src/command/client/search/keybindings/conditions.rs b/crates/turtle/src/command/client/search/keybindings/conditions.rs
new file mode 100644
index 00000000..055ae905
--- /dev/null
+++ b/crates/turtle/src/command/client/search/keybindings/conditions.rs
@@ -0,0 +1,801 @@
+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,
+ OriginalInputEmpty,
+ ListAtEnd,
+ ListAtStart,
+ NoResults,
+ HasResults,
+ HasContext,
+}
+
+/// 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,
+ /// Whether the original input (query passed to the TUI) was empty.
+ pub original_input_empty: bool,
+ /// Whether we use a search context of a command from the history.
+ pub has_context: bool,
+}
+
+// ---------------------------------------------------------------------------
+// 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::OriginalInputEmpty => ctx.original_input_empty,
+ 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,
+ ConditionAtom::HasContext => ctx.has_context,
+ }
+ }
+
+ /// 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),
+ "original-input-empty" => Ok(ConditionAtom::OriginalInputEmpty),
+ "list-at-end" => Ok(ConditionAtom::ListAtEnd),
+ "list-at-start" => Ok(ConditionAtom::ListAtStart),
+ "no-results" => Ok(ConditionAtom::NoResults),
+ "has-results" => Ok(ConditionAtom::HasResults),
+ "has-context" => Ok(ConditionAtom::HasContext),
+ _ => 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::OriginalInputEmpty => "original-input-empty",
+ ConditionAtom::ListAtEnd => "list-at-end",
+ ConditionAtom::ListAtStart => "list-at-start",
+ ConditionAtom::NoResults => "no-results",
+ ConditionAtom::HasResults => "has-results",
+ ConditionAtom::HasContext => "has-context",
+ }
+ }
+}
+
+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)
+ }
+}
+
+#[expect(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 {
+ ctx_with_original(cursor, width, byte_len, selected, len, false)
+ }
+
+ fn ctx_with_original(
+ cursor: usize,
+ width: usize,
+ byte_len: usize,
+ selected: usize,
+ len: usize,
+ original_input_empty: bool,
+ ) -> EvalContext {
+ EvalContext {
+ cursor_position: cursor,
+ input_width: width,
+ input_byte_len: byte_len,
+ selected_index: selected,
+ results_len: len,
+ original_input_empty,
+ has_context: false,
+ }
+ }
+
+ // -- 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_original_input_empty() {
+ // original_input_empty = true
+ assert!(
+ ConditionAtom::OriginalInputEmpty.evaluate(&ctx_with_original(0, 0, 0, 0, 10, true))
+ );
+ // original_input_empty = false
+ assert!(
+ !ConditionAtom::OriginalInputEmpty.evaluate(&ctx_with_original(0, 0, 0, 0, 10, false))
+ );
+ // original_input_empty is independent of current input state
+ assert!(
+ ConditionAtom::OriginalInputEmpty.evaluate(&ctx_with_original(0, 5, 5, 0, 10, true))
+ );
+ }
+
+ #[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_has_context() {
+ let mut context = ctx(0, 0, 0, 0, 0);
+ assert!(!ConditionAtom::HasContext.evaluate(&context));
+ context.has_context = true;
+ assert!(ConditionAtom::HasContext.evaluate(&context));
+ }
+
+ #[test]
+ fn atom_parse_round_trip() {
+ let conditions = [
+ "cursor-at-start",
+ "cursor-at-end",
+ "input-empty",
+ "original-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/turtle/src/command/client/search/keybindings/defaults.rs b/crates/turtle/src/command/client/search/keybindings/defaults.rs
new file mode 100644
index 00000000..c8401e37
--- /dev/null
+++ b/crates/turtle/src/command/client/search/keybindings/defaults.rs
@@ -0,0 +1,1286 @@
+use std::collections::HashMap;
+
+use crate::atuin_client::settings::{KeyBindingConfig, Settings};
+use tracing::warn;
+
+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.
+///
+/// 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) {
+ km.bind(key("ctrl-c"), Action::ReturnOriginal);
+ km.bind(key("ctrl-g"), Action::ReturnOriginal);
+ km.bind(key("ctrl-o"), Action::ToggleTab);
+
+ // Tab: always returns selection without executing (unlike Enter which respects enter_accept)
+ km.bind(key("tab"), Action::ReturnSelection);
+}
+
+/// 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.
+#[expect(clippy::too_many_lines)]
+pub fn default_emacs_keymap(settings: &Settings) -> Keymap {
+ let mut km = Keymap::new();
+ add_common_bindings(&mut km);
+
+ 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, Action::ReturnSelection),
+ 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, Action::ReturnSelection),
+ 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, Action::ReturnSelection),
+ 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);
+
+ // 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);
+
+ // --- Vim cursor movement ---
+ km.bind(key("0"), Action::CursorStart);
+ km.bind(key("$"), Action::CursorEnd);
+ km.bind(key("w"), Action::CursorWordRight);
+ km.bind(key("b"), Action::CursorWordLeft);
+ km.bind(key("e"), Action::CursorWordEnd);
+
+ // --- Vim editing ---
+ km.bind(key("x"), Action::DeleteCharAfter);
+ km.bind(key("d d"), Action::ClearLine);
+ km.bind(key("D"), Action::ClearToEnd);
+ km.bind(key("C"), Action::VimChangeToEnd);
+
+ // --- 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);
+
+ // --- Accept ---
+ let accept = accept_action(settings);
+ km.bind(key("enter"), accept);
+
+ 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).
+///
+/// The inspector shows details about the selected history item and has no
+/// text input, so we build a minimal keymap with only inspector-relevant
+/// bindings. We respect the user's `keymap_mode` to provide vim-style j/k
+/// navigation for vim users.
+pub fn default_inspector_keymap(settings: &Settings) -> Keymap {
+ use crate::atuin_client::settings::KeymapMode;
+
+ let mut km = Keymap::new();
+
+ // Common bindings (same as search tab)
+ 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"), Action::ReturnSelection);
+ km.bind(key("ctrl-o"), Action::ToggleTab);
+
+ // Accept behavior respects enter_accept setting
+ let accept = if settings.enter_accept {
+ Action::Accept
+ } else {
+ Action::ReturnSelection
+ };
+ km.bind(key("enter"), accept);
+
+ // Inspector-specific: delete history entry
+ km.bind(key("ctrl-d"), Action::Delete);
+
+ // Inspector navigation
+ km.bind(key("up"), Action::InspectPrevious);
+ km.bind(key("down"), Action::InspectNext);
+ km.bind(key("pageup"), Action::InspectPrevious);
+ km.bind(key("pagedown"), Action::InspectNext);
+
+ // For vim users, add j/k navigation
+ if matches!(
+ settings.keymap_mode,
+ KeymapMode::VimNormal | KeymapMode::VimInsert
+ ) {
+ km.bind(key("j"), Action::InspectNext);
+ km.bind(key("k"), Action::InspectPrevious);
+ }
+
+ 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("D"), Action::DeleteAll);
+ km.bind(key("a"), Action::CursorStart);
+ km.bind_conditional(
+ key("c"),
+ vec![
+ KeyRule::when(ConditionAtom::HasContext, Action::ClearContext),
+ KeyRule::always(Action::SwitchContext),
+ ],
+ );
+
+ 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) => {
+ warn!("invalid key in keymap config: {key_str:?}: {e}");
+ continue;
+ }
+ };
+ match parse_binding_config(binding_cfg) {
+ Ok(binding) => {
+ keymap.bindings.insert(key, binding);
+ }
+ Err(e) => {
+ warn!("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 crate::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
+ 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,
+ original_input_empty: false,
+ has_context: false,
+ }
+ }
+
+ 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::ReturnSelection));
+ }
+
+ #[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)
+ );
+ }
+
+ #[test]
+ fn vim_normal_enter_returns_selection() {
+ // enter_accept=false in test defaults → ReturnSelection
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ km.resolve(&key("enter"), &ctx),
+ Some(Action::ReturnSelection)
+ );
+ }
+
+ #[test]
+ fn vim_normal_enter_accept_true_uses_accept() {
+ let mut settings = default_settings();
+ settings.enter_accept = true;
+ let km = default_vim_normal_keymap(&settings);
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("enter"), &ctx), Some(Action::Accept));
+ }
+
+ // -- 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 crate::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 crate::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 crate::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 crate::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 crate::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 crate::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 crate::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 crate::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 crate::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 crate::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());
+ }
+
+ #[test]
+ fn original_input_empty_condition_in_config() {
+ use crate::atuin_client::settings::{KeyBindingConfig, KeyRuleConfig};
+ use std::collections::HashMap;
+
+ let mut settings = default_settings();
+ // Configure esc to: if original-input-empty -> return-query, else return-original
+ settings.keymap.emacs = HashMap::from([(
+ "esc".to_string(),
+ KeyBindingConfig::Rules(vec![
+ KeyRuleConfig {
+ when: Some("original-input-empty".to_string()),
+ action: "return-query".to_string(),
+ },
+ KeyRuleConfig {
+ when: None,
+ action: "return-original".to_string(),
+ },
+ ]),
+ )]);
+
+ let set = KeymapSet::from_settings(&settings);
+
+ // When original input was empty, should return-query
+ let ctx_original_empty = EvalContext {
+ cursor_position: 0,
+ input_width: 5,
+ input_byte_len: 5,
+ selected_index: 0,
+ results_len: 10,
+ original_input_empty: true,
+ has_context: false,
+ };
+ assert_eq!(
+ set.emacs.resolve(&key("esc"), &ctx_original_empty),
+ Some(Action::ReturnQuery),
+ "esc with original_input_empty=true should return-query"
+ );
+
+ // When original input was not empty, should return-original
+ let ctx_original_not_empty = EvalContext {
+ cursor_position: 0,
+ input_width: 5,
+ input_byte_len: 5,
+ selected_index: 0,
+ results_len: 10,
+ original_input_empty: false,
+ has_context: false,
+ };
+ assert_eq!(
+ set.emacs.resolve(&key("esc"), &ctx_original_not_empty),
+ Some(Action::ReturnOriginal),
+ "esc with original_input_empty=false should return-original"
+ );
+ }
+}
diff --git a/crates/turtle/src/command/client/search/keybindings/key.rs b/crates/turtle/src/command/client/search/keybindings/key.rs
new file mode 100644
index 00000000..c2eb31c6
--- /dev/null
+++ b/crates/turtle/src/command/client/search/keybindings/key.rs
@@ -0,0 +1,629 @@
+use std::fmt;
+
+use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MediaKeyCode};
+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)]
+#[expect(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,
+ Insert,
+ Up,
+ Down,
+ Left,
+ Right,
+ Home,
+ End,
+ PageUp,
+ PageDown,
+ Space,
+ F(u8),
+ Media(MediaKeyCode),
+}
+
+/// 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) -> Option<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 Some(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,
+ // BackTab is sent by many terminals for Shift+Tab
+ KeyCode::BackTab => {
+ return Some(SingleKey {
+ code: KeyCodeValue::Tab,
+ ctrl,
+ alt,
+ shift: true,
+ super_key,
+ });
+ }
+ KeyCode::Backspace => KeyCodeValue::Backspace,
+ KeyCode::Delete => KeyCodeValue::Delete,
+ KeyCode::Insert => KeyCodeValue::Insert,
+ 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,
+ KeyCode::F(n) => KeyCodeValue::F(n),
+ KeyCode::Media(m) => KeyCodeValue::Media(m),
+ _ => return None,
+ };
+
+ Some(SingleKey {
+ code,
+ ctrl,
+ alt,
+ 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,
+ "insert" | "ins" => KeyCodeValue::Insert,
+ "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,
+ s if s.starts_with('f') && s.len() > 1 => {
+ // Parse function keys like "f1", "f12"
+ if let Ok(n) = s[1..].parse::<u8>() {
+ if (1..=24).contains(&n) {
+ KeyCodeValue::F(n)
+ } else {
+ return Err(format!("function key out of range: {key_part}"));
+ }
+ } else {
+ return Err(format!("unknown key: {key_part}"));
+ }
+ }
+ "[" => KeyCodeValue::Char('['),
+ "]" => KeyCodeValue::Char(']'),
+ "?" => KeyCodeValue::Char('?'),
+ "/" => KeyCodeValue::Char('/'),
+ "$" => KeyCodeValue::Char('$'),
+ // Media keys (no dashes - the parser splits on dash for modifiers)
+ "play" => KeyCodeValue::Media(MediaKeyCode::Play),
+ "pause" => KeyCodeValue::Media(MediaKeyCode::Pause),
+ "playpause" => KeyCodeValue::Media(MediaKeyCode::PlayPause),
+ "stop" => KeyCodeValue::Media(MediaKeyCode::Stop),
+ "fastforward" => KeyCodeValue::Media(MediaKeyCode::FastForward),
+ "rewind" => KeyCodeValue::Media(MediaKeyCode::Rewind),
+ "tracknext" => KeyCodeValue::Media(MediaKeyCode::TrackNext),
+ "trackprevious" => KeyCodeValue::Media(MediaKeyCode::TrackPrevious),
+ "record" => KeyCodeValue::Media(MediaKeyCode::Record),
+ "lowervolume" => KeyCodeValue::Media(MediaKeyCode::LowerVolume),
+ "raisevolume" => KeyCodeValue::Media(MediaKeyCode::RaiseVolume),
+ "mutevolume" | "mute" => KeyCodeValue::Media(MediaKeyCode::MuteVolume),
+ _ => {
+ 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::Insert => write!(f, "insert"),
+ 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"),
+ KeyCodeValue::F(n) => write!(f, "f{n}"),
+ KeyCodeValue::Media(m) => match m {
+ MediaKeyCode::Play => write!(f, "play"),
+ MediaKeyCode::Pause => write!(f, "media-pause"),
+ MediaKeyCode::PlayPause => write!(f, "playpause"),
+ MediaKeyCode::Stop => write!(f, "stop"),
+ MediaKeyCode::FastForward => write!(f, "fastforward"),
+ MediaKeyCode::Rewind => write!(f, "rewind"),
+ MediaKeyCode::TrackNext => write!(f, "tracknext"),
+ MediaKeyCode::TrackPrevious => write!(f, "trackprevious"),
+ MediaKeyCode::Record => write!(f, "record"),
+ MediaKeyCode::LowerVolume => write!(f, "lowervolume"),
+ MediaKeyCode::RaiseVolume => write!(f, "raisevolume"),
+ MediaKeyCode::MuteVolume => write!(f, "mutevolume"),
+ MediaKeyCode::Reverse => write!(f, "reverse"),
+ },
+ }
+ }
+}
+
+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).unwrap();
+ 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).unwrap();
+ 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).unwrap();
+ 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).unwrap();
+ 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).unwrap();
+ 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).unwrap();
+ 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).unwrap();
+ 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());
+ }
+
+ #[test]
+ fn parse_function_keys() {
+ let k = SingleKey::parse("f1").unwrap();
+ assert_eq!(k.code, KeyCodeValue::F(1));
+ assert!(!k.ctrl && !k.alt && !k.shift);
+
+ let k = SingleKey::parse("F12").unwrap();
+ assert_eq!(k.code, KeyCodeValue::F(12));
+
+ let k = SingleKey::parse("ctrl-f5").unwrap();
+ assert_eq!(k.code, KeyCodeValue::F(5));
+ assert!(k.ctrl);
+
+ // F24 is valid (some keyboards have extended function keys)
+ let k = SingleKey::parse("f24").unwrap();
+ assert_eq!(k.code, KeyCodeValue::F(24));
+
+ // F0 and F25+ are invalid
+ assert!(SingleKey::parse("f0").is_err());
+ assert!(SingleKey::parse("f25").is_err());
+ }
+
+ #[test]
+ fn from_event_function_keys() {
+ let event = KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE);
+ let k = SingleKey::from_event(&event).unwrap();
+ assert_eq!(k.code, KeyCodeValue::F(1));
+
+ let event = KeyEvent::new(KeyCode::F(12), KeyModifiers::CONTROL);
+ let k = SingleKey::from_event(&event).unwrap();
+ assert_eq!(k.code, KeyCodeValue::F(12));
+ assert!(k.ctrl);
+ }
+
+ #[test]
+ fn display_function_keys() {
+ let k = SingleKey::parse("f1").unwrap();
+ assert_eq!(k.to_string(), "f1");
+
+ let k = SingleKey::parse("ctrl-f12").unwrap();
+ assert_eq!(k.to_string(), "ctrl-f12");
+ }
+
+ #[test]
+ fn function_key_round_trip() {
+ let cases = ["f1", "f12", "ctrl-f5", "alt-f10"];
+ 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}");
+ }
+ }
+
+ #[test]
+ fn from_event_function_key_matches_parsed() {
+ let event = KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE);
+ let from_event = SingleKey::from_event(&event).unwrap();
+ let parsed = SingleKey::parse("f12").unwrap();
+ assert_eq!(from_event, parsed);
+ }
+
+ #[test]
+ fn from_event_backtab_becomes_shift_tab() {
+ // Many terminals send BackTab for Shift+Tab
+ let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE);
+ let k = SingleKey::from_event(&event).unwrap();
+ assert_eq!(k.code, KeyCodeValue::Tab);
+ assert!(k.shift);
+ assert!(!k.ctrl && !k.alt);
+ }
+
+ #[test]
+ fn from_event_backtab_matches_parsed_shift_tab() {
+ let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE);
+ let from_event = SingleKey::from_event(&event).unwrap();
+ let parsed = SingleKey::parse("shift-tab").unwrap();
+ assert_eq!(from_event, parsed);
+ }
+
+ #[test]
+ fn from_event_backtab_with_ctrl() {
+ // BackTab with ctrl modifier
+ let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::CONTROL);
+ let k = SingleKey::from_event(&event).unwrap();
+ assert_eq!(k.code, KeyCodeValue::Tab);
+ assert!(k.shift);
+ assert!(k.ctrl);
+ }
+
+ #[test]
+ fn parse_insert_key() {
+ let k = SingleKey::parse("insert").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Insert);
+ assert!(!k.ctrl && !k.alt && !k.shift);
+
+ let k = SingleKey::parse("ins").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Insert);
+
+ let k = SingleKey::parse("ctrl-insert").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Insert);
+ assert!(k.ctrl);
+ }
+
+ #[test]
+ fn from_event_insert_key() {
+ let event = KeyEvent::new(KeyCode::Insert, KeyModifiers::NONE);
+ let k = SingleKey::from_event(&event).unwrap();
+ assert_eq!(k.code, KeyCodeValue::Insert);
+ }
+
+ #[test]
+ fn insert_key_round_trip() {
+ let k = KeyInput::parse("insert").unwrap();
+ let display = k.to_string();
+ assert_eq!(display, "insert");
+ let k2 = KeyInput::parse(&display).unwrap();
+ assert_eq!(k, k2);
+ }
+}
diff --git a/crates/turtle/src/command/client/search/keybindings/keymap.rs b/crates/turtle/src/command/client/search/keybindings/keymap.rs
new file mode 100644
index 00000000..0d362863
--- /dev/null
+++ b/crates/turtle/src/command/client/search/keybindings/keymap.rs
@@ -0,0 +1,233 @@
+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`.
+ #[expect(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,
+ original_input_empty: false,
+ has_context: false,
+ }
+ }
+
+ #[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/turtle/src/command/client/search/keybindings/mod.rs b/crates/turtle/src/command/client/search/keybindings/mod.rs
new file mode 100644
index 00000000..3b6eb2b2
--- /dev/null
+++ b/crates/turtle/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;
+#[expect(unused_imports)]
+pub use conditions::{ConditionAtom, ConditionExpr, EvalContext};
+pub use defaults::KeymapSet;
+#[expect(unused_imports)]
+pub use key::{KeyCodeValue, KeyInput, SingleKey};
+#[expect(unused_imports)]
+pub use keymap::{KeyBinding, KeyRule, Keymap};