From 9768d60399f344c7ceb0c2ecec6cfd2a0e191176 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 11 Feb 2026 12:33:41 -0800 Subject: feat: Add original-input-empty keybind condition (#3171) This PR adds a keybind condition called `original-input-empty` that is true when the TUI was invoked from an empty prompt line; users can use this to change behavior based on the original prompt's contents; for example: ```toml [keymap.emacs] "esc" = [ { when = "original-input-empty", action = "return-query" }, { action = "return-original" } ] ``` Thanks Hazilo in Discord for the suggestion. --- .../atuin/src/command/client/search/interactive.rs | 68 ++++++++++++++++++++++ .../client/search/keybindings/conditions.rs | 35 +++++++++++ .../command/client/search/keybindings/defaults.rs | 55 +++++++++++++++++ .../command/client/search/keybindings/keymap.rs | 1 + docs/docs/configuration/advanced-key-binding.md | 1 + 5 files changed, 160 insertions(+) diff --git a/crates/atuin/src/command/client/search/interactive.rs b/crates/atuin/src/command/client/search/interactive.rs index 7cddb163..b5186706 100644 --- a/crates/atuin/src/command/client/search/interactive.rs +++ b/crates/atuin/src/command/client/search/interactive.rs @@ -105,6 +105,7 @@ pub fn to_compactness(f: &Frame, settings: &Settings) -> Compactness { } #[allow(clippy::struct_field_names)] +#[allow(clippy::struct_excessive_bools)] pub struct State { history_count: i64, update_needed: Option, @@ -118,6 +119,7 @@ pub struct State { current_cursor: Option, tab_index: usize, pending_vim_key: Option, + original_input_empty: bool, pub inspecting_state: InspectingState, @@ -301,6 +303,7 @@ impl State { 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, }; // Convert KeyEvent to SingleKey @@ -1416,6 +1419,7 @@ pub async fn history( }, prefix: false, pending_vim_key: None, + original_input_empty: original_query.is_empty(), }; app.initialize_keymap_cursor(settings); @@ -1806,6 +1810,7 @@ mod tests { current_cursor: None, tab_index: 0, pending_vim_key: None, + original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, @@ -1859,6 +1864,7 @@ mod tests { current_cursor: None, tab_index: 0, pending_vim_key: None, + original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, @@ -1976,6 +1982,7 @@ mod tests { current_cursor: None, tab_index: 0, pending_vim_key: None, + original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, @@ -2033,6 +2040,7 @@ mod tests { current_cursor: None, tab_index: 0, pending_vim_key: None, + original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, @@ -2086,6 +2094,7 @@ mod tests { current_cursor: None, tab_index: 0, pending_vim_key: None, + original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, @@ -2135,6 +2144,7 @@ mod tests { current_cursor: None, tab_index: 0, pending_vim_key: None, + original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, @@ -2193,6 +2203,7 @@ mod tests { current_cursor: None, tab_index: 0, pending_vim_key: None, + original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, @@ -2252,6 +2263,7 @@ mod tests { current_cursor: None, tab_index: 0, pending_vim_key: None, + original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, @@ -2582,4 +2594,60 @@ mod tests { state.execute_action(&Action::ClearLine, &settings); assert_eq!(state.search.input.as_str(), ""); } + + #[test] + fn keymap_config_return_query() { + use 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, + update_needed: None, + 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, + }, + }, + engine: engines::engine(SearchMode::Fuzzy), + 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/atuin/src/command/client/search/keybindings/conditions.rs b/crates/atuin/src/command/client/search/keybindings/conditions.rs index ed414300..bc485713 100644 --- a/crates/atuin/src/command/client/search/keybindings/conditions.rs +++ b/crates/atuin/src/command/client/search/keybindings/conditions.rs @@ -8,6 +8,7 @@ pub enum ConditionAtom { CursorAtStart, CursorAtEnd, InputEmpty, + OriginalInputEmpty, ListAtEnd, ListAtStart, NoResults, @@ -46,6 +47,8 @@ pub struct EvalContext { 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, } // --------------------------------------------------------------------------- @@ -59,6 +62,7 @@ impl ConditionAtom { 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) } @@ -74,6 +78,7 @@ impl ConditionAtom { "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), @@ -88,6 +93,7 @@ impl ConditionAtom { 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", @@ -369,6 +375,17 @@ mod tests { 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, @@ -376,6 +393,7 @@ mod tests { input_byte_len: byte_len, selected_index: selected, results_len: len, + original_input_empty, } } @@ -400,6 +418,22 @@ mod tests { 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))); @@ -428,6 +462,7 @@ mod tests { "cursor-at-start", "cursor-at-end", "input-empty", + "original-input-empty", "list-at-end", "list-at-start", "no-results", diff --git a/crates/atuin/src/command/client/search/keybindings/defaults.rs b/crates/atuin/src/command/client/search/keybindings/defaults.rs index 121c59fe..64dca691 100644 --- a/crates/atuin/src/command/client/search/keybindings/defaults.rs +++ b/crates/atuin/src/command/client/search/keybindings/defaults.rs @@ -529,6 +529,7 @@ mod tests { input_byte_len: width, selected_index: selected, results_len: len, + original_input_empty: false, } } @@ -1217,4 +1218,58 @@ mod tests { modified.prefix = "x".to_string(); assert!(modified.has_non_default_values()); } + + #[test] + fn original_input_empty_condition_in_config() { + use 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, + }; + 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, + }; + 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/atuin/src/command/client/search/keybindings/keymap.rs b/crates/atuin/src/command/client/search/keybindings/keymap.rs index 4d91e180..bbf034b2 100644 --- a/crates/atuin/src/command/client/search/keybindings/keymap.rs +++ b/crates/atuin/src/command/client/search/keybindings/keymap.rs @@ -126,6 +126,7 @@ mod tests { input_byte_len: width, selected_index: selected, results_len: len, + original_input_empty: false, } } diff --git a/docs/docs/configuration/advanced-key-binding.md b/docs/docs/configuration/advanced-key-binding.md index 5027c5a2..1fe9c1e4 100644 --- a/docs/docs/configuration/advanced-key-binding.md +++ b/docs/docs/configuration/advanced-key-binding.md @@ -246,6 +246,7 @@ Conditions let a single key do different things depending on the current state. | `cursor-at-start` | The cursor is at position 0 | | `cursor-at-end` | The cursor is at the end of the input | | `input-empty` | The input line is empty (no text entered) | +| `original-input-empty` | The original query passed to the TUI was empty | | `list-at-start` | The selection is at the first entry (index 0) | | `list-at-end` | The selection is at the last entry | | `no-results` | The search returned zero results | -- cgit v1.3.1