use std::collections::HashSet;
use std::fs::File;
use std::io::{BufWriter, Write};
use std::time::{Duration, Instant};
use crossterm::{
cursor::{Hide, MoveTo, Show},
event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
execute, queue,
style::{Attribute, Color, ResetColor, SetAttribute, SetBackgroundColor, SetForegroundColor},
terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
};
#[cfg(not(target_os = "windows"))]
use crossterm::event::{
KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
};
use super::command::{FilterMode, HostFilter};
use super::config::{KeymapMode, PreviewConfig, RecallConfig};
use super::engine::{HistoryEntry, SearchEngine, format_relative_time};
const SCROLL_MARGIN: usize = 5;
fn sanitize_for_display(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\x1b' => {
if let Some(&next) = chars.peek()
&& next == '['
{
chars.next(); while let Some(&c) = chars.peek() {
chars.next();
if c.is_ascii_alphabetic() {
break;
}
}
}
}
'\n' | '\r' => result.push(' '),
'\x00'..='\x08' | '\x0b'..='\x0c' | '\x0e'..='\x1f' | '\x7f' => {}
'\t' => result.push(' '),
_ => result.push(c),
}
}
result
}
const PREVIEW_HEIGHT: usize = 5; const FLASH_DURATION_MS: u64 = 100;
fn deduplicate_entries(entries: Vec<HistoryEntry>) -> Vec<HistoryEntry> {
let mut seen = HashSet::new();
entries.into_iter().filter(|e| seen.insert(e.command.clone())).collect()
}
#[cfg(test)]
fn highlight_command(cmd: &str, query: &str, max_len: usize) -> Vec<(String, bool)> {
if query.is_empty() {
let truncated = if cmd.chars().count() > max_len {
let t: String = cmd.chars().take(max_len.saturating_sub(3)).collect();
format!("{t}...")
} else {
cmd.to_string()
};
return vec![(truncated, false)];
}
let cmd_lower = cmd.to_lowercase();
let query_lower = query.to_lowercase();
let mut spans = Vec::new();
let mut pos = 0;
let cmd_chars: Vec<char> = cmd.chars().collect();
let mut total_len = 0;
while let Some(match_start) = cmd_lower[pos..].find(&query_lower) {
let abs_start = pos + match_start;
let abs_end = abs_start + query_lower.len();
if abs_start > pos {
let text: String = cmd_chars
[char_pos_from_byte(cmd, pos)..char_pos_from_byte(cmd, abs_start)]
.iter()
.collect();
let text_len = text.chars().count();
if total_len + text_len > max_len.saturating_sub(3) {
let remaining = max_len.saturating_sub(3).saturating_sub(total_len);
let truncated: String = text.chars().take(remaining).collect();
spans.push((truncated, false));
spans.push(("...".to_string(), false));
return spans;
}
total_len += text_len;
spans.push((text, false));
}
let match_text: String = cmd_chars
[char_pos_from_byte(cmd, abs_start)..char_pos_from_byte(cmd, abs_end)]
.iter()
.collect();
let match_len = match_text.chars().count();
if total_len + match_len > max_len.saturating_sub(3) {
let remaining = max_len.saturating_sub(3).saturating_sub(total_len);
let truncated: String = match_text.chars().take(remaining).collect();
spans.push((truncated, true));
spans.push(("...".to_string(), false));
return spans;
}
total_len += match_len;
spans.push((match_text, true));
pos = abs_end;
}
if pos < cmd.len() {
let text: String = cmd_chars[char_pos_from_byte(cmd, pos)..].iter().collect();
let text_len = text.chars().count();
if total_len + text_len > max_len {
let remaining = max_len.saturating_sub(3).saturating_sub(total_len);
let truncated: String = text.chars().take(remaining).collect();
spans.push((truncated, false));
spans.push(("...".to_string(), false));
} else {
spans.push((text, false));
}
}
spans
}
#[cfg(test)]
fn char_pos_from_byte(s: &str, byte_pos: usize) -> usize {
s[..byte_pos].chars().count()
}
fn highlight_command_with_indices(
cmd: &str,
match_indices: &[u32],
max_len: usize,
) -> Vec<(String, bool)> {
if cmd.is_empty() {
return vec![];
}
let match_set: HashSet<u32> = match_indices.iter().copied().collect();
let chars: Vec<char> = cmd.chars().collect();
let mut spans = Vec::new();
let mut current_span = String::new();
let mut current_is_match = false;
for (i, &c) in chars.iter().enumerate() {
if i >= max_len.saturating_sub(3) {
if !current_span.is_empty() {
spans.push((current_span, current_is_match));
}
spans.push(("...".to_string(), false));
return spans;
}
let is_match = match_set.contains(&(i as u32));
if is_match != current_is_match && !current_span.is_empty() {
spans.push((std::mem::take(&mut current_span), current_is_match));
}
current_is_match = is_match;
current_span.push(c);
}
if !current_span.is_empty() {
spans.push((current_span, current_is_match));
}
spans
}
fn format_duration(secs: i64) -> String {
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{}h {}m {}s", secs / 3600, (secs % 3600) / 60, secs % 60)
}
}
pub struct RecallTui {
engine: SearchEngine,
filter_mode: FilterMode,
host_filter: HostFilter,
entries: Vec<HistoryEntry>,
filtered_indices: Vec<(usize, Vec<u32>)>,
query: String,
selected_index: usize,
scroll_offset: usize, tty: File,
term_height: u16,
term_width: u16,
keymap_mode: KeymapMode,
show_preview: bool,
preview_config: PreviewConfig,
shell_mode: bool, flash_until: Option<Instant>, #[cfg(not(target_os = "windows"))]
keyboard_enhanced: bool,
}
impl RecallTui {
pub fn new(
mut engine: SearchEngine,
initial_mode: FilterMode,
initial_query: Option<String>,
config: &RecallConfig,
shell_mode: bool,
) -> Result<Self, Box<dyn std::error::Error>> {
let query = initial_query.as_deref().unwrap_or("").to_string();
let host_filter = HostFilter::default();
let entries = deduplicate_entries(engine.load_entries(initial_mode, host_filter, None)?);
terminal::enable_raw_mode()?;
let mut tty = File::options().read(true).write(true).open("/dev/tty")?;
#[cfg(not(target_os = "windows"))]
let keyboard_enhanced = execute!(
tty,
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
)
.is_ok();
execute!(
tty,
EnterAlternateScreen,
Hide,
Clear(ClearType::All),
Clear(ClearType::Purge),
MoveTo(0, 0)
)?;
tty.flush()?;
let (term_width, term_height) = terminal::size()?;
let filtered_indices = if query.is_empty() {
(0..entries.len()).map(|i| (i, Vec::new())).collect()
} else {
engine
.filter_entries(&entries, &query)
.into_iter()
.map(|(entry, indices)| {
let idx = entries
.iter()
.position(|e| std::ptr::eq(e, entry))
.expect("filter_entries returned entry not in entries slice");
(idx, indices)
})
.collect()
};
let mut tui = RecallTui {
engine,
filter_mode: initial_mode,
host_filter,
entries,
filtered_indices,
query,
selected_index: 0,
scroll_offset: 0,
tty,
term_height,
term_width,
keymap_mode: config.initial_keymap_mode(),
show_preview: config.show_preview,
preview_config: config.preview.clone(),
shell_mode,
flash_until: None,
#[cfg(not(target_os = "windows"))]
keyboard_enhanced,
};
tui.adjust_scroll_for_selection();
Ok(tui)
}
fn results_height(&self) -> usize {
let base = self.term_height.saturating_sub(2) as usize;
if self.show_preview { base.saturating_sub(PREVIEW_HEIGHT) } else { base }
}
fn adjust_scroll_for_selection(&mut self) {
let results_height = self.results_height();
if results_height == 0 || self.filtered_indices.is_empty() {
self.scroll_offset = 0;
return;
}
let view_bottom = self.scroll_offset;
let view_top = view_bottom + results_height.saturating_sub(1);
if self.selected_index < view_bottom + SCROLL_MARGIN {
self.scroll_offset = self.selected_index.saturating_sub(SCROLL_MARGIN);
} else if self.selected_index > view_top.saturating_sub(SCROLL_MARGIN) {
let new_view_top = self.selected_index + SCROLL_MARGIN;
self.scroll_offset = new_view_top.saturating_sub(results_height.saturating_sub(1));
}
let max_scroll = self.filtered_indices.len().saturating_sub(results_height);
self.scroll_offset = self.scroll_offset.min(max_scroll);
}
fn reload_entries(&mut self) {
match self.engine.load_entries(self.filter_mode, self.host_filter, None) {
Ok(entries) => {
self.entries = deduplicate_entries(entries);
}
Err(e) => {
eprintln!("pxh recall: failed to reload entries: {e}");
self.flash();
return;
}
}
self.update_filtered_indices();
}
fn update_filtered_indices(&mut self) {
if self.query.is_empty() {
self.filtered_indices = (0..self.entries.len()).map(|i| (i, Vec::new())).collect();
} else {
self.filtered_indices = self
.engine
.filter_entries(&self.entries, &self.query)
.into_iter()
.map(|(entry, indices)| {
let idx = self
.entries
.iter()
.position(|e| std::ptr::eq(e, entry))
.expect("filter_entries returned entry not in entries slice");
(idx, indices)
})
.collect();
}
if self.selected_index >= self.filtered_indices.len() {
self.selected_index = 0;
}
self.adjust_scroll_for_selection();
}
fn flash(&mut self) {
self.flash_until = Some(Instant::now() + Duration::from_millis(FLASH_DURATION_MS));
}
fn is_flashing(&self) -> bool {
self.flash_until.is_some_and(|until| Instant::now() < until)
}
fn toggle_host_filter(&mut self) {
self.host_filter = match self.host_filter {
HostFilter::ThisHost => HostFilter::AllHosts,
HostFilter::AllHosts => HostFilter::ThisHost,
};
self.reload_entries();
}
fn toggle_filter_mode(&mut self) {
self.filter_mode = match self.filter_mode {
FilterMode::Directory => FilterMode::Global,
FilterMode::Global => FilterMode::Directory,
};
self.reload_entries();
}
pub fn run(&mut self) -> Result<Option<String>, Box<dyn std::error::Error>> {
loop {
self.draw()?;
if !event::poll(Duration::from_millis(100))? {
continue;
}
if let Event::Key(key) = event::read()? {
let action = self.handle_key(key)?;
match action {
KeyAction::Continue => continue,
KeyAction::Select | KeyAction::Edit => {
self.cleanup()?;
if !self.shell_mode {
self.print_entry_details();
return Ok(None);
}
let prefix =
if matches!(action, KeyAction::Select) { "run" } else { "edit" };
let result =
self.get_selected_command().map(|cmd| format!("{prefix}:{cmd}"));
return Ok(result);
}
KeyAction::Cancel => {
self.cleanup()?;
return Ok(None);
}
}
}
}
}
fn print_entry_details(&self) {
let Some(entry) = self
.filtered_indices
.get(self.selected_index)
.and_then(|(idx, _)| self.entries.get(*idx))
else {
return;
};
let mut stdout = std::io::stdout();
let _ = execute!(stdout, SetAttribute(Attribute::Bold));
println!("{}", entry.command);
let _ = execute!(stdout, SetAttribute(Attribute::Reset));
if let Some(ts) = entry.timestamp {
let datetime = chrono::DateTime::from_timestamp(ts, 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| "?".to_string());
let relative = format_relative_time(Some(ts));
let _ = execute!(stdout, SetForegroundColor(Color::Cyan));
print!(" Time: ");
let _ = execute!(stdout, ResetColor);
println!("{datetime} ({relative} ago)");
}
if let Some(ref dir) = entry.working_directory {
let _ = execute!(stdout, SetForegroundColor(Color::Cyan));
print!(" Dir: ");
let _ = execute!(stdout, ResetColor);
println!("{}", String::from_utf8_lossy(dir));
}
if let Some(status) = entry.exit_status {
let _ = execute!(stdout, SetForegroundColor(Color::Cyan));
print!("Status: ");
let _ = execute!(stdout, ResetColor);
if status == 0 {
let _ = execute!(stdout, SetForegroundColor(Color::Green));
println!("0 (success)");
} else {
let _ = execute!(stdout, SetForegroundColor(Color::Red));
println!("{status} (error)");
}
let _ = execute!(stdout, ResetColor);
}
if let Some(secs) = entry.duration_secs {
let _ = execute!(stdout, SetForegroundColor(Color::Cyan));
print!(" Took: ");
let _ = execute!(stdout, ResetColor);
println!("{}", format_duration(secs));
}
if let Some(ref host) = entry.hostname {
let _ = execute!(stdout, SetForegroundColor(Color::Cyan));
print!(" Host: ");
let _ = execute!(stdout, ResetColor);
println!("{}", String::from_utf8_lossy(host));
}
}
pub fn draw_once(&mut self) -> Result<(), Box<dyn std::error::Error>> {
self.draw()?;
self.cleanup()?;
Ok(())
}
fn cleanup(&mut self) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(not(target_os = "windows"))]
if self.keyboard_enhanced {
let _ = execute!(self.tty, PopKeyboardEnhancementFlags);
}
execute!(self.tty, Show, LeaveAlternateScreen)?;
terminal::disable_raw_mode()?;
Ok(())
}
fn get_selected_command(&self) -> Option<String> {
self.filtered_indices
.get(self.selected_index)
.and_then(|(idx, _)| self.entries.get(*idx))
.map(|e| e.command.clone())
}
fn handle_key(&mut self, key: KeyEvent) -> Result<KeyAction, Box<dyn std::error::Error>> {
match self.keymap_mode {
KeymapMode::Emacs => self.handle_key_emacs(key),
KeymapMode::VimInsert => self.handle_key_vim_insert(key),
KeymapMode::VimNormal => self.handle_key_vim_normal(key),
}
}
fn handle_common_key(&mut self, key: KeyEvent) -> Option<KeyAction> {
match key.code {
KeyCode::Enter => Some(KeyAction::Select),
KeyCode::Tab => Some(KeyAction::Edit),
KeyCode::Char('c' | 'd') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(KeyAction::Cancel)
}
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.move_selection_up();
Some(KeyAction::Continue)
}
KeyCode::Up => {
self.move_selection_up();
Some(KeyAction::Continue)
}
KeyCode::Down => {
self.move_selection_down();
Some(KeyAction::Continue)
}
KeyCode::PageUp => {
self.page_up();
Some(KeyAction::Continue)
}
KeyCode::PageDown => {
self.page_down();
Some(KeyAction::Continue)
}
KeyCode::Char(c @ '1'..='9') if key.modifiers.contains(KeyModifiers::ALT) => {
let num = c.to_digit(10).unwrap() as usize;
let target_index = self.selected_index + (num - 1);
if target_index < self.filtered_indices.len() {
self.selected_index = target_index;
return Some(KeyAction::Select);
}
Some(KeyAction::Continue)
}
KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.toggle_host_filter();
Some(KeyAction::Continue)
}
KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.toggle_filter_mode();
Some(KeyAction::Continue)
}
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(KeyAction::Edit)
}
_ => None,
}
}
fn handle_key_emacs(&mut self, key: KeyEvent) -> Result<KeyAction, Box<dyn std::error::Error>> {
if let Some(action) = self.handle_common_key(key) {
return Ok(action);
}
match key.code {
KeyCode::Esc => Ok(KeyAction::Cancel),
KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.move_selection_up();
Ok(KeyAction::Continue)
}
KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.move_selection_down();
Ok(KeyAction::Continue)
}
KeyCode::Backspace => {
self.delete_last_char();
Ok(KeyAction::Continue)
}
KeyCode::Right => Ok(KeyAction::Edit),
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.clear_query();
Ok(KeyAction::Continue)
}
KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.delete_last_word();
Ok(KeyAction::Continue)
}
KeyCode::Char(_) if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.flash();
Ok(KeyAction::Continue)
}
KeyCode::Char(c) => {
self.insert_char(c);
Ok(KeyAction::Continue)
}
_ => Ok(KeyAction::Continue),
}
}
fn handle_key_vim_insert(
&mut self,
key: KeyEvent,
) -> Result<KeyAction, Box<dyn std::error::Error>> {
if let Some(action) = self.handle_common_key(key) {
return Ok(action);
}
match key.code {
KeyCode::Esc => {
self.keymap_mode = KeymapMode::VimNormal;
Ok(KeyAction::Continue)
}
KeyCode::Backspace => {
self.delete_last_char();
Ok(KeyAction::Continue)
}
KeyCode::Right => Ok(KeyAction::Edit),
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.clear_query();
Ok(KeyAction::Continue)
}
KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.delete_last_word();
Ok(KeyAction::Continue)
}
KeyCode::Char(_) if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.flash();
Ok(KeyAction::Continue)
}
KeyCode::Char(c) => {
self.insert_char(c);
Ok(KeyAction::Continue)
}
_ => Ok(KeyAction::Continue),
}
}
fn handle_key_vim_normal(
&mut self,
key: KeyEvent,
) -> Result<KeyAction, Box<dyn std::error::Error>> {
if let Some(action) = self.handle_common_key(key) {
return Ok(action);
}
match key.code {
KeyCode::Esc => Ok(KeyAction::Cancel),
KeyCode::Char('j') => {
self.move_selection_down();
Ok(KeyAction::Continue)
}
KeyCode::Char('k') => {
self.move_selection_up();
Ok(KeyAction::Continue)
}
KeyCode::Char('l') | KeyCode::Right => Ok(KeyAction::Edit),
KeyCode::Char('i' | 'a' | 'A' | 'I') => {
self.keymap_mode = KeymapMode::VimInsert;
Ok(KeyAction::Continue)
}
KeyCode::Char('x' | 'X') => {
self.delete_last_char();
Ok(KeyAction::Continue)
}
KeyCode::Char(_) if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.flash();
Ok(KeyAction::Continue)
}
_ => Ok(KeyAction::Continue),
}
}
fn move_selection_up(&mut self) {
if self.selected_index + 1 < self.filtered_indices.len() {
self.selected_index += 1;
self.adjust_scroll_for_selection();
}
}
fn move_selection_down(&mut self) {
if self.selected_index > 0 {
self.selected_index -= 1;
self.adjust_scroll_for_selection();
}
}
fn page_up(&mut self) {
let page = self.results_height().saturating_sub(2);
let max_index = self.filtered_indices.len().saturating_sub(1);
self.selected_index = (self.selected_index + page).min(max_index);
self.adjust_scroll_for_selection();
}
fn page_down(&mut self) {
let page = self.results_height().saturating_sub(2);
self.selected_index = self.selected_index.saturating_sub(page);
self.adjust_scroll_for_selection();
}
fn insert_char(&mut self, c: char) {
self.query.push(c);
self.update_filtered_indices();
}
fn delete_last_char(&mut self) {
if self.query.pop().is_some() {
self.update_filtered_indices();
}
}
fn clear_query(&mut self) {
if !self.query.is_empty() {
self.query.clear();
self.update_filtered_indices();
}
}
fn delete_last_word(&mut self) {
if !self.query.is_empty() {
let trimmed_len = self.query.trim_end().len();
let word_start =
self.query[..trimmed_len].rfind(char::is_whitespace).map(|i| i + 1).unwrap_or(0);
self.query.truncate(word_start);
self.update_filtered_indices();
}
}
fn draw_preview<W: Write>(
&self,
w: &mut W,
start_y: u16,
width: u16,
) -> Result<(), Box<dyn std::error::Error>> {
let entry = self
.filtered_indices
.get(self.selected_index)
.and_then(|(idx, _)| self.entries.get(*idx));
queue!(w, MoveTo(0, start_y), Clear(ClearType::CurrentLine))?;
queue!(w, SetForegroundColor(Color::DarkGrey))?;
write!(w, "{}", "─".repeat(width as usize))?;
queue!(w, ResetColor)?;
let Some(entry) = entry else {
for row in 1..PREVIEW_HEIGHT {
queue!(w, MoveTo(0, start_y + row as u16), Clear(ClearType::CurrentLine))?;
}
return Ok(());
};
queue!(w, MoveTo(0, start_y + 1), Clear(ClearType::CurrentLine))?;
let safe_cmd = sanitize_for_display(&entry.command);
let cmd_display: String = if safe_cmd.chars().count() > width as usize - 2 {
let truncated: String = safe_cmd.chars().take(width as usize - 5).collect();
format!("{truncated}...")
} else {
safe_cmd
};
write!(w, " {cmd_display}")?;
queue!(w, MoveTo(0, start_y + 2), Clear(ClearType::CurrentLine))?;
let mut info_parts: Vec<String> = Vec::new();
if self.preview_config.show_directory
&& let Some(ref dir) = entry.working_directory
{
info_parts.push(format!("Dir: {}", String::from_utf8_lossy(dir)));
}
if self.preview_config.show_timestamp
&& let Some(ts) = entry.timestamp
{
let datetime = chrono::DateTime::from_timestamp(ts, 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| "?".to_string());
info_parts.push(format!("Time: {datetime}"));
}
queue!(w, SetForegroundColor(Color::DarkGrey))?;
write!(w, " {}", info_parts.join(" "))?;
queue!(w, ResetColor)?;
queue!(w, MoveTo(0, start_y + 3), Clear(ClearType::CurrentLine))?;
let mut status_parts: Vec<String> = Vec::new();
if self.preview_config.show_exit_status
&& let Some(status) = entry.exit_status
{
let status_str = if status == 0 {
"Status: 0 (ok)".to_string()
} else {
format!("Status: {status} (error)")
};
status_parts.push(status_str);
}
if self.preview_config.show_duration
&& let Some(secs) = entry.duration_secs
{
status_parts.push(format!("Duration: {}", format_duration(secs)));
}
if self.preview_config.show_hostname
&& let Some(ref host) = entry.hostname
{
status_parts.push(format!("Host: {}", String::from_utf8_lossy(host)));
}
queue!(w, SetForegroundColor(Color::DarkGrey))?;
write!(w, " {}", status_parts.join(" "))?;
queue!(w, ResetColor)?;
queue!(w, MoveTo(0, start_y + 4), Clear(ClearType::CurrentLine))?;
Ok(())
}
fn draw(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let (term_width, term_height) = terminal::size()?;
self.term_width = term_width;
self.term_height = term_height;
let results_height = self.results_height();
let preview_start_y = results_height as u16;
let input_y = term_height.saturating_sub(2);
let help_y = term_height.saturating_sub(1);
let mut w = BufWriter::new(&self.tty);
let flashing = self.is_flashing();
write!(w, "\x1b[?7l")?;
for row in 0..results_height {
queue!(w, MoveTo(0, row as u16), Clear(ClearType::CurrentLine))?;
let offset_from_bottom = results_height - 1 - row;
let entry_index = self.scroll_offset + offset_from_bottom;
if entry_index >= self.filtered_indices.len() {
continue;
}
let (idx, ref match_indices) = self.filtered_indices[entry_index];
let entry = &self.entries[idx];
let time_str = format_relative_time(entry.timestamp);
let is_selected = entry_index == self.selected_index;
let quick_num =
if entry_index >= self.selected_index && entry_index < self.selected_index + 9 {
Some(entry_index - self.selected_index + 1)
} else {
None
};
if is_selected {
queue!(w, SetBackgroundColor(Color::DarkGrey))?;
}
if let Some(n) = quick_num {
queue!(w, SetForegroundColor(Color::Yellow))?;
write!(w, "{n}")?;
queue!(w, ResetColor)?;
if is_selected {
queue!(w, SetBackgroundColor(Color::DarkGrey))?;
write!(w, ">")?;
} else {
write!(w, " ")?;
}
} else if is_selected {
write!(w, " >")?;
} else {
write!(w, " ")?;
}
queue!(w, SetForegroundColor(Color::DarkGrey))?;
write!(w, "{time_str} ")?;
queue!(w, ResetColor)?;
if is_selected {
queue!(w, SetBackgroundColor(Color::DarkGrey))?;
}
let host_prefix = if self.host_filter == HostFilter::AllHosts {
entry.hostname.as_ref().and_then(|h| {
if !self.engine.is_this_host(h) {
let short =
String::from_utf8_lossy(h).split('.').next().unwrap_or("?").to_string();
Some(format!("@{short}: "))
} else {
None
}
})
} else {
None
};
let host_prefix_len = host_prefix.as_ref().map_or(0, |p| p.chars().count());
if let Some(ref prefix) = host_prefix {
queue!(w, SetForegroundColor(Color::Magenta))?;
write!(w, "{prefix}")?;
queue!(w, ResetColor)?;
if is_selected {
queue!(w, SetBackgroundColor(Color::DarkGrey))?;
}
}
let safe_cmd = sanitize_for_display(&entry.command);
let prefix_len = 9 + host_prefix_len; let max_cmd_len = term_width.saturating_sub(prefix_len as u16) as usize;
let spans = highlight_command_with_indices(&safe_cmd, match_indices, max_cmd_len);
for (text, is_match) in spans {
if is_match {
queue!(w, SetAttribute(Attribute::Bold))?;
queue!(w, SetForegroundColor(Color::Cyan))?;
}
write!(w, "{text}")?;
if is_match {
queue!(w, SetAttribute(Attribute::Reset))?;
queue!(w, ResetColor)?;
if is_selected {
queue!(w, SetBackgroundColor(Color::DarkGrey))?;
}
}
}
queue!(w, ResetColor)?;
}
if self.show_preview {
self.draw_preview(&mut w, preview_start_y, term_width)?;
}
queue!(w, MoveTo(0, input_y), Clear(ClearType::CurrentLine))?;
write!(w, "> {}", self.query)?;
let host_str = match self.host_filter {
HostFilter::ThisHost => {
let hostname = self.engine.primary_hostname();
let short_host =
String::from_utf8_lossy(hostname).split('.').next().unwrap_or("?").to_string();
format!("[{short_host}]")
}
HostFilter::AllHosts => "[All Hosts]".to_string(),
};
let dir_str = match self.filter_mode {
FilterMode::Directory => {
let dir = self.engine.working_directory();
let name = dir
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "?".to_string());
format!("[Dir: {name}]")
}
FilterMode::Global => "[Global]".to_string(),
};
let mode_str = format!("{host_str} {dir_str}");
let mode_x = term_width.saturating_sub(mode_str.len() as u16 + 1);
queue!(w, MoveTo(mode_x, input_y), SetForegroundColor(Color::Cyan))?;
write!(w, "{mode_str}")?;
queue!(w, ResetColor)?;
queue!(w, MoveTo(0, help_y), Clear(ClearType::CurrentLine))?;
if flashing {
queue!(w, SetBackgroundColor(Color::White), SetForegroundColor(Color::Black))?;
} else {
queue!(w, SetForegroundColor(Color::DarkGrey))?;
}
let help_text = match self.keymap_mode {
KeymapMode::Emacs => {
"↑↓/^R Nav Enter Select Tab/→/^E Edit ^G Dir ^H Host ^C/^D Quit Alt-1-9"
}
KeymapMode::VimInsert | KeymapMode::VimNormal => {
"j/k Nav Enter Select Tab/→/^E Edit ^G Dir ^H Host ^C/^D Quit Esc Mode Alt-1-9"
}
};
write!(w, "{help_text}")?;
queue!(w, ResetColor)?;
queue!(w, MoveTo(2 + self.query.len() as u16, input_y))?;
write!(w, "\x1b[?7h")?;
w.flush()?;
Ok(())
}
}
enum KeyAction {
Continue,
Select,
Edit,
Cancel,
}
impl Drop for RecallTui {
fn drop(&mut self) {
#[cfg(not(target_os = "windows"))]
if self.keyboard_enhanced {
let _ = execute!(self.tty, PopKeyboardEnhancementFlags);
}
let _ = execute!(self.tty, Show, LeaveAlternateScreen);
let _ = terminal::disable_raw_mode();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_preserves_normal_text() {
assert_eq!(sanitize_for_display("hello world"), "hello world");
assert_eq!(sanitize_for_display("ls -la /tmp"), "ls -la /tmp");
}
#[test]
fn test_sanitize_preserves_box_drawing() {
assert_eq!(sanitize_for_display("┌History───┐"), "┌History───┐");
assert_eq!(sanitize_for_display("│ cell │"), "│ cell │");
assert_eq!(sanitize_for_display("└───────┘"), "└───────┘");
}
#[test]
fn test_sanitize_preserves_unicode() {
assert_eq!(sanitize_for_display("héllo wörld"), "héllo wörld");
assert_eq!(sanitize_for_display("日本語"), "日本語");
assert_eq!(sanitize_for_display("emoji 🎉 test"), "emoji 🎉 test");
}
#[test]
fn test_sanitize_strips_ansi_escape_sequences() {
assert_eq!(sanitize_for_display("\x1b[31mred\x1b[0m"), "red");
assert_eq!(sanitize_for_display("\x1b[1;32mbold green\x1b[0m"), "bold green");
assert_eq!(sanitize_for_display("\x1b[H"), ""); assert_eq!(sanitize_for_display("\x1b[2J"), ""); assert_eq!(sanitize_for_display("\x1b[10;20H"), "");
assert_eq!(sanitize_for_display("before\x1b[31mred\x1b[0mafter"), "beforeredafter");
}
#[test]
fn test_sanitize_converts_newlines_to_spaces() {
assert_eq!(sanitize_for_display("line1\nline2"), "line1 line2");
assert_eq!(sanitize_for_display("line1\r\nline2"), "line1 line2");
assert_eq!(sanitize_for_display("a\nb\nc"), "a b c");
}
#[test]
fn test_sanitize_converts_tabs_to_spaces() {
assert_eq!(sanitize_for_display("col1\tcol2"), "col1 col2");
assert_eq!(sanitize_for_display("\t\tindented"), " indented");
}
#[test]
fn test_sanitize_strips_control_characters() {
assert_eq!(sanitize_for_display("hello\x07world"), "helloworld"); assert_eq!(sanitize_for_display("hello\x08world"), "helloworld"); assert_eq!(sanitize_for_display("a\x00b\x01c"), "abc"); assert_eq!(sanitize_for_display("test\x7fdelete"), "testdelete"); }
#[test]
fn test_sanitize_handles_binary_garbage() {
let binary_garbage = "cmd\x1b[2J\x1b[H\x00\x01\x02\x03visible\x1b[31m";
assert_eq!(sanitize_for_display(binary_garbage), "cmdvisible");
}
#[test]
fn test_sanitize_handles_incomplete_escape_sequences() {
assert_eq!(sanitize_for_display("text\x1b"), "text");
assert_eq!(sanitize_for_display("text\x1b["), "text");
assert_eq!(sanitize_for_display("text\x1b[123"), "text");
}
#[test]
fn test_sanitize_empty_string() {
assert_eq!(sanitize_for_display(""), "");
}
#[test]
fn test_highlight_no_query() {
let spans = highlight_command("ls -la", "", 100);
assert_eq!(spans, vec![("ls -la".to_string(), false)]);
}
#[test]
fn test_highlight_single_match() {
let spans = highlight_command("grep foo bar", "foo", 100);
assert_eq!(
spans,
vec![
("grep ".to_string(), false),
("foo".to_string(), true),
(" bar".to_string(), false),
]
);
}
#[test]
fn test_highlight_case_insensitive() {
let spans = highlight_command("grep FOO bar", "foo", 100);
assert_eq!(
spans,
vec![
("grep ".to_string(), false),
("FOO".to_string(), true),
(" bar".to_string(), false),
]
);
}
#[test]
fn test_highlight_multiple_matches() {
let spans = highlight_command("foo bar foo", "foo", 100);
assert_eq!(
spans,
vec![
("foo".to_string(), true),
(" bar ".to_string(), false),
("foo".to_string(), true),
]
);
}
#[test]
fn test_highlight_at_start() {
let spans = highlight_command("foo bar", "foo", 100);
assert_eq!(spans, vec![("foo".to_string(), true), (" bar".to_string(), false),]);
}
#[test]
fn test_highlight_at_end() {
let spans = highlight_command("bar foo", "foo", 100);
assert_eq!(spans, vec![("bar ".to_string(), false), ("foo".to_string(), true),]);
}
#[test]
fn test_highlight_truncation() {
let spans = highlight_command("very long command here", "", 10);
assert_eq!(spans, vec![("very lo...".to_string(), false)]);
}
#[test]
fn test_highlight_no_match() {
let spans = highlight_command("ls -la", "xyz", 100);
assert_eq!(spans, vec![("ls -la".to_string(), false)]);
}
#[test]
fn test_highlight_multibyte_query() {
let spans = highlight_command("find 日本語 here", "日本語", 100);
assert_eq!(
spans,
vec![
("find ".to_string(), false),
("日本語".to_string(), true),
(" here".to_string(), false),
]
);
}
#[test]
fn test_highlight_multibyte_command() {
let spans = highlight_command("echo 日本語 foo bar", "foo", 100);
assert_eq!(
spans,
vec![
("echo 日本語 ".to_string(), false),
("foo".to_string(), true),
(" bar".to_string(), false),
]
);
}
#[test]
fn test_highlight_empty_command() {
let spans = highlight_command("", "foo", 100);
assert!(spans.is_empty());
}
#[test]
fn test_highlight_empty_both() {
let spans = highlight_command("", "", 100);
assert_eq!(spans, vec![("".to_string(), false)]);
}
#[test]
fn test_deduplicate_empty_list() {
use super::deduplicate_entries;
let deduped = deduplicate_entries(vec![]);
assert!(deduped.is_empty());
}
#[test]
fn test_deduplicate_keeps_first_occurrence() {
use super::deduplicate_entries;
use crate::recall::engine::HistoryEntry;
let entries = vec![
HistoryEntry {
command: "cmd1".to_string(),
timestamp: Some(100),
working_directory: None,
exit_status: None,
duration_secs: None,
hostname: None,
},
HistoryEntry {
command: "cmd2".to_string(),
timestamp: Some(90),
working_directory: None,
exit_status: None,
duration_secs: None,
hostname: None,
},
HistoryEntry {
command: "cmd1".to_string(), timestamp: Some(80),
working_directory: None,
exit_status: None,
duration_secs: None,
hostname: None,
},
];
let deduped = deduplicate_entries(entries);
assert_eq!(deduped.len(), 2);
assert_eq!(deduped[0].command, "cmd1");
assert_eq!(deduped[0].timestamp, Some(100)); assert_eq!(deduped[1].command, "cmd2");
}
#[test]
fn test_fuzzy_highlight_no_indices() {
use super::highlight_command_with_indices;
let spans = highlight_command_with_indices("git fetch origin", &[], 100);
assert_eq!(spans, vec![("git fetch origin".to_string(), false)]);
}
#[test]
fn test_fuzzy_highlight_scattered_matches() {
use super::highlight_command_with_indices;
let spans = highlight_command_with_indices("git fetch origin", &[0, 4, 10], 100);
assert_eq!(
spans,
vec![
("g".to_string(), true),
("it ".to_string(), false),
("f".to_string(), true),
("etch ".to_string(), false),
("o".to_string(), true),
("rigin".to_string(), false),
]
);
}
#[test]
fn test_fuzzy_highlight_contiguous_matches() {
use super::highlight_command_with_indices;
let spans = highlight_command_with_indices("grep foo bar", &[5, 6, 7], 100);
assert_eq!(
spans,
vec![
("grep ".to_string(), false),
("foo".to_string(), true),
(" bar".to_string(), false),
]
);
}
#[test]
fn test_fuzzy_highlight_truncation() {
use super::highlight_command_with_indices;
let spans = highlight_command_with_indices("very long command here", &[0, 5], 10);
assert_eq!(
spans,
vec![
("v".to_string(), true),
("ery ".to_string(), false),
("l".to_string(), true),
("o".to_string(), false),
("...".to_string(), false),
]
);
}
#[test]
fn test_fuzzy_highlight_empty_command() {
use super::highlight_command_with_indices;
let spans = highlight_command_with_indices("", &[0, 1, 2], 100);
assert!(spans.is_empty());
}
#[test]
fn test_fuzzy_highlight_match_at_start() {
use super::highlight_command_with_indices;
let spans = highlight_command_with_indices("foo bar", &[0, 1, 2], 100);
assert_eq!(spans, vec![("foo".to_string(), true), (" bar".to_string(), false),]);
}
#[test]
fn test_fuzzy_highlight_match_at_end() {
use super::highlight_command_with_indices;
let spans = highlight_command_with_indices("foo bar", &[4, 5, 6], 100);
assert_eq!(spans, vec![("foo ".to_string(), false), ("bar".to_string(), true),]);
}
}