aboutsummaryrefslogtreecommitdiffstats
path: root/crates/turtle/src/command/client/search/interactive.rs
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-09 21:43:23 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-09 21:43:23 +0200
commit3223d93cb3c77ab02aa0a35f2a8314e447cee9a4 (patch)
tree3971a1f37f5fe3cadb747c5229a0d54e868e45e3 /crates/turtle/src/command/client/search/interactive.rs
parentfix(client/sync): Pass through precise error on `SyncError::WrongKey` (diff)
downloadatuin-3223d93cb3c77ab02aa0a35f2a8314e447cee9a4.zip
chore: Separate daemon, client, server, and lib into crates
Diffstat (limited to 'crates/turtle/src/command/client/search/interactive.rs')
-rw-r--r--crates/turtle/src/command/client/search/interactive.rs3024
1 files changed, 0 insertions, 3024 deletions
diff --git a/crates/turtle/src/command/client/search/interactive.rs b/crates/turtle/src/command/client/search/interactive.rs
deleted file mode 100644
index b8cf53db..00000000
--- a/crates/turtle/src/command/client/search/interactive.rs
+++ /dev/null
@@ -1,3024 +0,0 @@
-use std::{
- io::{IsTerminal, Write, stdout},
- time::Duration,
-};
-
-#[cfg(unix)]
-use std::io::Read as _;
-
-use crate::{
- atuin_client::{
- database::ClientSqlite,
- theme::{style_annotation, style_base, style_important},
- },
- 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, 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::{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::Rect,
- 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(crate) enum InputAction {
- Accept(usize),
- AcceptInspecting,
- Copy(usize),
- Delete(usize),
- DeleteAllMatching(usize),
- ReturnOriginal,
- ReturnQuery,
- Continue,
- Redraw,
- SwitchContext(Option<usize>),
-}
-
-#[derive(Clone)]
-pub(crate) struct InspectingState {
- current: Option<HistoryId>,
- next: Option<HistoryId>,
- previous: Option<HistoryId>,
-}
-
-impl InspectingState {
- pub(crate) fn move_to_previous(&mut self) {
- let previous = self.previous.clone();
- self.reset();
- self.current = previous;
- }
-
- pub(crate) fn move_to_next(&mut self) {
- let next = self.next.clone();
- self.reset();
- self.current = next;
- }
-
- pub(crate) fn reset(&mut self) {
- self.current = None;
- self.next = None;
- self.previous = None;
- }
-}
-
-pub(crate) 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(crate) 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(crate) inspecting_state: InspectingState,
-
- keymaps: KeymapSet,
- search: SearchState,
- engine: Box<dyn SearchEngine>,
- now: Box<dyn Fn() -> OffsetDateTime + Send>,
-}
-
-#[derive(Clone, Copy)]
-pub(crate) 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 ClientSqlite,
- 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;
- drop(execute!(stdout(), Self::cast_cursor_style(style)));
- }
- }
-
- pub(crate) 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(crate) 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
- }
- }
-
- fn draw(
- &mut self,
- f: &mut Frame<'_>,
- results: &[History],
- stats: Option<HistoryStats>,
- inspecting: Option<&History>,
- settings: &Settings,
-
- popup_mode: bool,
- ) {
- let area = f.area();
- if popup_mode {
- f.render_widget(Clear, area);
- }
- self.draw_inner(f, area, results, stats, inspecting, settings);
- }
-
- #[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,
- ) {
- 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(style_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();
- f.render_widget(title, header_chunks[0]);
-
- let help = self.build_help(settings);
- f.render_widget(help, header_chunks[1]);
-
- let stats_tab = self.build_stats();
- 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(),
- 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,
- 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(),
- );
- #[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<'a>() -> Paragraph<'a> {
- let title = {
- let style: Style = Style::from_crossterm(style_base());
- Paragraph::new(Text::from(Span::styled(
- format!("Atuin v{VERSION}"),
- style.add_modifier(Modifier::BOLD),
- )))
- };
- title.alignment(Alignment::Left)
- }
-
- fn build_help(&self, settings: &Settings) -> 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(style_annotation()))
- .alignment(Alignment::Center)
- }
-
- fn build_stats(&self) -> Paragraph<'_> {
- Paragraph::new(Text::from(Span::raw(format!(
- "history count: {}",
- self.history_count,
- ))))
- .style(Style::from_crossterm(style_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,
-
- 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,
- 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,
- ) -> 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(style_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(Self::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(Self::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 {
- Self::Stdout(stdout) => stdout.write(buf),
- #[cfg(unix)]
- Self::Tty(file) => file.write(buf),
- }
- }
-
- fn flush(&mut self) -> std::io::Result<()> {
- match self {
- Self::Stdout(stdout) => stdout.flush(),
- #[cfg(unix)]
- Self::Tty(file) => file.flush(),
- }
- }
-}
-
-/// Screen state captured from atuin pty-proxy's screen server.
-#[cfg(unix)]
-struct SavedScreen {
- rows: u16,
- 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.
- drop(execute!(
- stdout,
- MoveTo(popup_rect.x, target_row),
- crossterm::style::SetAttribute(crossterm::style::Attribute::Reset),
- ));
- drop(write!(
- stdout,
- "{:width$}",
- "",
- width = popup_rect.width as usize
- ));
- drop(execute!(stdout, MoveTo(popup_rect.x, target_row)));
-
- if let Some(row_bytes) = saved.rows_data.get(source_row) {
- drop(stdout.write_all(row_bytes));
- }
- }
-
- drop(execute!(
- stdout,
- MoveTo(
- saved.cursor_col,
- saved.cursor_row.saturating_sub(scroll_offset)
- )
- ));
- drop(stdout.flush());
-}
-
-struct Stdout {
- writer: TerminalWriter,
- inline_mode: bool,
- no_mouse: bool,
-}
-
-impl Stdout {
- pub(crate) 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::too_many_lines, clippy::cognitive_complexity)]
-pub(crate) async fn history(
- query: &[String],
- settings: &Settings,
- mut db: ClientSqlite,
- history_store: &HistoryStore,
-) -> 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();
- drop(execute!(stdout, MoveTo(0, term_rows - 1)));
- for _ in 0..scroll {
- drop(writeln!(stdout));
- }
- drop(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.
- drop(queue!(
- raw_stdout,
- crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
- ));
- for row in popup_rect.y..popup_rect.y.saturating_add(popup_rect.height) {
- drop(queue!(raw_stdout, MoveTo(popup_rect.x, row)));
- drop(write!(
- raw_stdout,
- "{:width$}",
- "",
- width = popup_rect.width as usize
- ));
- }
- drop(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,
- 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, 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(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(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::new().unwrap()
- };
-
- let settings_preview_auto_h2 = Settings {
- preview: Preview {
- strategy: PreviewStrategy::Auto,
- },
- show_preview: true,
- max_preview_height: 2,
- ..Settings::new().unwrap()
- };
-
- let settings_preview_h4 = Settings {
- preview: Preview {
- strategy: PreviewStrategy::Static,
- },
- show_preview: true,
- max_preview_height: 4,
- ..Settings::new().unwrap()
- };
-
- let settings_preview_fixed = Settings {
- preview: Preview {
- strategy: PreviewStrategy::Fixed,
- },
- show_preview: true,
- max_preview_height: 15,
- ..Settings::new().unwrap()
- };
-
- let cmd_60: History = History::capture()
- .timestamp(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(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(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::new().unwrap();
- 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::new().unwrap();
- 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::new().unwrap();
-
- 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::new().unwrap();
-
- 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::new().unwrap();
-
- 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]
- #[expect(clippy::similar_names)]
- fn test_vim_ctrl_u_d_half_page_scroll() {
- use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-
- let settings = Settings::new().unwrap();
-
- 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]
- #[expect(clippy::similar_names)]
- fn test_vim_ctrl_f_b_full_page_scroll() {
- use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-
- let settings = Settings::new().unwrap();
-
- 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::new().unwrap();
- 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::new().unwrap();
- 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::new().unwrap();
- 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::new().unwrap();
- 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::new().unwrap();
- 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::new().unwrap();
- 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::new().unwrap();
- 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::new().unwrap();
- 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::new().unwrap();
- 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::new().unwrap();
- 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::new().unwrap();
- 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::new().unwrap();
- 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::new().unwrap();
- 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::new().unwrap();
- 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::new().unwrap();
-
- 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::new().unwrap();
- 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::new().unwrap();
- 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::new().unwrap();
- 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::new().unwrap();
- 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::new().unwrap();
- 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::new().unwrap();
- 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::new().unwrap();
- 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::new().unwrap();
- 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::new().unwrap();
- 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::new().unwrap();
-
- // 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::new().unwrap();
-
- // 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::new().unwrap();
- // 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"
- );
- }
-}