From 9223e4848dcbdafaa5e5000b0c2b370a1054a704 Mon Sep 17 00:00:00 2001 From: pagedmov Date: Thu, 5 Mar 2026 00:16:07 -0500 Subject: [PATCH] implemented Ctrl+R command history searching and command history expansion with '\!' --- nix/hm-module.nix | 6 + src/builtin/keymap.rs | 5 - src/expand.rs | 7 +- src/readline/complete.rs | 461 +++++++++++++++++++++------------ src/readline/highlight.rs | 24 +- src/readline/history.rs | 57 +++- src/readline/keys.rs | 7 + src/readline/linebuf.rs | 86 +++++- src/readline/mod.rs | 156 ++++++++++- src/readline/term.rs | 29 +-- src/readline/vimode/insert.rs | 9 + src/readline/vimode/normal.rs | 9 + src/readline/vimode/replace.rs | 9 + src/readline/vimode/visual.rs | 9 + src/state.rs | 18 ++ 15 files changed, 676 insertions(+), 216 deletions(-) diff --git a/nix/hm-module.nix b/nix/hm-module.nix index d988b81..b45d333 100644 --- a/nix/hm-module.nix +++ b/nix/hm-module.nix @@ -81,6 +81,12 @@ in "pre-mode-change" "post-mode-change" "on-exit" + "on-history-open" + "on-history-close" + "on-history-select" + "on-completion-start" + "on-completion-cancel" + "on-completion-select" ])) (list: list != []); description = "The events that trigger this autocmd"; }; diff --git a/src/builtin/keymap.rs b/src/builtin/keymap.rs index 0e7ac70..1592379 100644 --- a/src/builtin/keymap.rs +++ b/src/builtin/keymap.rs @@ -111,11 +111,6 @@ impl KeyMap { expand_keymap(&self.action) } pub fn compare(&self, other: &[KeyEvent]) -> KeyMapMatch { - log::debug!( - "Comparing keymap keys {:?} with input {:?}", - self.keys_expanded(), - other - ); let ours = self.keys_expanded(); if other == ours { KeyMapMatch::IsExact diff --git a/src/expand.rs b/src/expand.rs index 4896a58..49d7bda 100644 --- a/src/expand.rs +++ b/src/expand.rs @@ -1076,7 +1076,6 @@ pub fn unescape_str(raw: &str) -> String { } } '$' => { - log::debug!("Found ANSI-C quoting"); chars.next(); while let Some(q_ch) = chars.next() { match q_ch { @@ -1232,7 +1231,6 @@ pub fn unescape_str(raw: &str) -> String { } } '$' if chars.peek() == Some(&'\'') => { - log::debug!("Found ANSI-C quoting"); chars.next(); result.push(markers::SNG_QUOTE); while let Some(q_ch) = chars.next() { @@ -1406,7 +1404,6 @@ impl FromStr for ParamExp { )) }; - log::debug!("Parsing parameter expansion: '{:?}'", s); // Handle indirect var expansion: ${!var} if let Some(var) = s.strip_prefix('!') { @@ -1423,7 +1420,6 @@ impl FromStr for ParamExp { return Ok(RemShortestPrefix(rest.to_string())); } if let Some(rest) = s.strip_prefix("%%") { - log::debug!("Matched longest suffix pattern: '{}'", rest); return Ok(RemLongestSuffix(rest.to_string())); } else if let Some(rest) = s.strip_prefix('%') { return Ok(RemShortestSuffix(rest.to_string())); @@ -2363,7 +2359,7 @@ pub fn parse_key_alias(alias: &str) -> Option { } } - let key = match *key_name.first()? { + let key = match key_name.first()?.to_uppercase().as_str() { "CR" => KeyCode::Char('\r'), "ENTER" | "RETURN" => KeyCode::Enter, "ESC" | "ESCAPE" => KeyCode::Esc, @@ -2378,6 +2374,7 @@ pub fn parse_key_alias(alias: &str) -> Option { "RIGHT" => KeyCode::Right, "HOME" => KeyCode::Home, "END" => KeyCode::End, + "CMD" => KeyCode::ExMode, "PGUP" | "PAGEUP" => KeyCode::PageUp, "PGDN" | "PAGEDOWN" => KeyCode::PageDown, k if k.len() == 1 => KeyCode::Char(k.chars().next().unwrap()), diff --git a/src/readline/complete.rs b/src/readline/complete.rs index 36000c0..780f2e0 100644 --- a/src/readline/complete.rs +++ b/src/readline/complete.rs @@ -528,6 +528,12 @@ pub enum CompResponse { Consumed, // key was handled, but completion remains active } +pub enum SelectorResponse { + Accept(String), + Dismiss, + Consumed, +} + pub trait Completer { fn complete( &mut self, @@ -702,23 +708,28 @@ impl QueryEditor { } #[derive(Clone, Debug)] -pub struct FuzzyCompleter { - completer: SimpleCompleter, +pub struct FuzzySelector { query: QueryEditor, filtered: Vec, candidates: Vec, cursor: ClampedUsize, + number_candidates: bool, old_layout: Option, max_height: usize, scroll_offset: usize, active: bool, - /// Context from the prompt: width of the line above the fuzzy window prompt_line_width: u16, - /// Context from the prompt: cursor column on the line above the fuzzy window prompt_cursor_col: u16, + title: String, } -impl FuzzyCompleter { +#[derive(Clone, Debug)] +pub struct FuzzyCompleter { + completer: SimpleCompleter, + pub selector: FuzzySelector, +} + +impl FuzzySelector { const BOT_LEFT: &str = "\x1b[90m╰\x1b[0m"; const BOT_RIGHT: &str = "\x1b[90m╯\x1b[0m"; const TOP_LEFT: &str = "\x1b[90m╭\x1b[0m"; @@ -730,29 +741,122 @@ impl FuzzyCompleter { const PROMPT_ARROW: &str = "\x1b[1;36m>\x1b[0m"; const TREE_LEFT: &str = "\x1b[90m├\x1b[0m"; const TREE_RIGHT: &str = "\x1b[90m┤\x1b[0m"; - //const TREE_BOT: &str = "\x1b[90m┴\x1b[0m"; - //const TREE_TOP: &str = "\x1b[90m┬\x1b[0m"; - //const CROSS: &str = "\x1b[90m┼\x1b[0m"; + + pub fn new(title: impl Into) -> Self { + Self { + max_height: 8, + query: QueryEditor::default(), + filtered: vec![], + candidates: vec![], + cursor: ClampedUsize::new(0, 0, true), + number_candidates: false, + old_layout: None, + scroll_offset: 0, + active: false, + prompt_line_width: 0, + prompt_cursor_col: 0, + title: title.into(), + } + } + + pub fn number_candidates(self, enable: bool) -> Self { + Self { + number_candidates: enable, + ..self + } + } + + pub fn activate(&mut self, candidates: Vec) { + self.active = true; + self.candidates = candidates; + self.score_candidates(); + } + + pub fn set_query(&mut self, query: String) { + self.query.linebuf = LineBuf::new().with_initial(&query, query.len()); + self.query.update_scroll_offset(); + self.score_candidates(); + } + + pub fn reset(&mut self) { + self.query.clear(); + self.filtered.clear(); + self.candidates.clear(); + self.cursor = ClampedUsize::new(0, 0, true); + self.old_layout = None; + self.scroll_offset = 0; + self.active = false; + } + + pub fn reset_stay_active(&mut self) { + if self.active { + self.query.clear(); + self.score_candidates(); + } + } + + pub fn is_active(&self) -> bool { + self.active + } + + pub fn selected_candidate(&self) -> Option { + self + .filtered + .get(self.cursor.get()) + .map(|c| c.content.clone()) + } + + pub fn set_prompt_line_context(&mut self, line_width: u16, cursor_col: u16) { + self.prompt_line_width = line_width; + self.prompt_cursor_col = cursor_col; + } + + fn candidate_height(&self, idx: usize) -> usize { + self.filtered.get(idx) + .map(|c| c.content.trim_end().lines().count().max(1)) + .unwrap_or(1) + } fn get_window(&mut self) -> &[ScoredCandidate] { - let height = self.filtered.len().min(self.max_height); - self.update_scroll_offset(); - &self.filtered[self.scroll_offset..self.scroll_offset + height] + let mut lines = 0; + let mut end = self.scroll_offset; + while end < self.filtered.len() { + if lines >= self.max_height { + break; + } + lines += self.candidate_height(end); + end += 1; + } + + &self.filtered[self.scroll_offset..end] } + pub fn update_scroll_offset(&mut self) { - let height = self.filtered.len().min(self.max_height); - if self.cursor.get() < self.scroll_offset + 1 { - self.scroll_offset = self.cursor.ret_sub(1); + let cursor = self.cursor.get(); + + // Scroll up: cursor above window + if cursor < self.scroll_offset { + self.scroll_offset = cursor; + return; } - if self.cursor.get() >= self.scroll_offset + height.saturating_sub(1) { - self.scroll_offset = self.cursor.ret_sub(height.saturating_sub(2)); + + // Scroll down: ensure all candidates from scroll_offset through cursor + // fit within max_height rendered lines + loop { + let mut lines = 0; + let last = cursor.min(self.filtered.len().saturating_sub(1)); + for idx in self.scroll_offset..=last { + lines += self.candidate_height(idx); + } + if lines <= self.max_height || self.scroll_offset >= cursor { + break; + } + self.scroll_offset += 1; } - self.scroll_offset = self - .scroll_offset - .min(self.filtered.len().saturating_sub(height)); } + pub fn score_candidates(&mut self) { let mut scored: Vec<_> = self .candidates @@ -769,83 +873,13 @@ impl FuzzyCompleter { self.cursor.set_max(scored.len()); self.filtered = scored; } -} -impl Default for FuzzyCompleter { - fn default() -> Self { - Self { - max_height: 8, - completer: SimpleCompleter::default(), - query: QueryEditor::default(), - filtered: vec![], - candidates: vec![], - cursor: ClampedUsize::new(0, 0, true), - old_layout: None, - scroll_offset: 0, - active: false, - prompt_line_width: 0, - prompt_cursor_col: 0, - } - } -} - -impl Completer for FuzzyCompleter { - fn set_prompt_line_context(&mut self, line_width: u16, cursor_col: u16) { - self.prompt_line_width = line_width; - self.prompt_cursor_col = cursor_col; - } - fn reset_stay_active(&mut self) { - if self.is_active() { - self.query.clear(); - self.score_candidates(); - } - } - fn get_completed_line(&self, _candidate: &str) -> String { - log::debug!("Getting completed line for candidate: {}", _candidate); - - let selected = &self.filtered[self.cursor.get()].content; - log::debug!("Selected candidate: {}", selected); - let (start, end) = self.completer.token_span; - log::debug!("Token span: ({}, {})", start, end); - let ret = format!( - "{}{}{}", - &self.completer.original_input[..start], - selected, - &self.completer.original_input[end..] - ); - log::debug!("Completed line: {}", ret); - ret - } - fn complete( - &mut self, - line: String, - cursor_pos: usize, - direction: i32, - ) -> ShResult> { - self.completer.complete(line, cursor_pos, direction)?; - let candidates: Vec<_> = self.completer.candidates.clone(); - if candidates.is_empty() { - self.completer.reset(); - self.active = false; - return Ok(None); - } else if candidates.len() == 1 { - self.filtered = candidates.into_iter().map(ScoredCandidate::from).collect(); - let completed = self.get_completed_line(&self.filtered[0].content); - self.active = false; - return Ok(Some(completed)); - } - self.active = true; - self.candidates = candidates; - self.score_candidates(); - Ok(None) - } - - fn handle_key(&mut self, key: K) -> ShResult { + pub fn handle_key(&mut self, key: K) -> ShResult { match key { K(C::Char('D'), M::CTRL) | K(C::Esc, M::NONE) => { self.active = false; self.filtered.clear(); - Ok(CompResponse::Dismiss) + Ok(SelectorResponse::Dismiss) } K(C::Enter, M::NONE) => { self.active = false; @@ -854,76 +888,30 @@ impl Completer for FuzzyCompleter { .get(self.cursor.get()) .map(|c| c.content.clone()) { - Ok(CompResponse::Accept(selected)) + Ok(SelectorResponse::Accept(selected)) } else { - Ok(CompResponse::Dismiss) + Ok(SelectorResponse::Dismiss) } } K(C::Tab, M::SHIFT) | K(C::Up, M::NONE) => { self.cursor.wrap_sub(1); self.update_scroll_offset(); - Ok(CompResponse::Consumed) + Ok(SelectorResponse::Consumed) } K(C::Tab, M::NONE) | K(C::Down, M::NONE) => { self.cursor.wrap_add(1); self.update_scroll_offset(); - Ok(CompResponse::Consumed) + Ok(SelectorResponse::Consumed) } _ => { self.query.handle_key(key)?; self.score_candidates(); - Ok(CompResponse::Consumed) + Ok(SelectorResponse::Consumed) } } } - fn clear(&mut self, writer: &mut TermWriter) -> ShResult<()> { - if let Some(layout) = self.old_layout.take() { - let (new_cols, _) = get_win_size(*TTY_FILENO); - // The fuzzy window is one continuous auto-wrapped block (no hard - // newlines between rows). After a resize the terminal re-joins - // soft wraps and re-wraps as a flat buffer. - let total_cells = layout.rows as u32 * layout.cols as u32; - let physical_rows = if new_cols > 0 { - total_cells.div_ceil(new_cols as u32) as u16 - } else { - layout.rows - }; - let cursor_offset = layout.cols as u32 + layout.cursor_col as u32; - let cursor_phys_row = if new_cols > 0 { - (cursor_offset / new_cols as u32) as u16 - } else { - 1 - }; - let lines_below = physical_rows.saturating_sub(cursor_phys_row + 1); - // The prompt line above the \n may have wrapped (e.g. due to PSR - // filling to t_cols). Compute how many extra rows that adds - // between the prompt cursor and the fuzzy content. - let gap_extra = if new_cols > 0 && layout.preceding_line_width > new_cols { - let wrap_rows = (layout.preceding_line_width as u32).div_ceil(new_cols as u32) as u16; - let cursor_wrap_row = layout.preceding_cursor_col / new_cols; - wrap_rows.saturating_sub(cursor_wrap_row + 1) - } else { - 0 - }; - - let mut buf = String::new(); - if lines_below > 0 { - write!(buf, "\x1b[{}B", lines_below).unwrap(); - } - for _ in 0..physical_rows { - buf.push_str("\x1b[2K\x1b[A"); - } - buf.push_str("\x1b[2K"); - // Clear extra rows from prompt line wrapping (PSR) - for _ in 0..gap_extra { - buf.push_str("\x1b[A\x1b[2K"); - } - writer.flush_write(&buf)?; - } - Ok(()) - } - fn draw(&mut self, writer: &mut TermWriter) -> ShResult<()> { + pub fn draw(&mut self, writer: &mut TermWriter) -> ShResult<()> { if !self.active { return Ok(()); } @@ -939,13 +927,19 @@ impl Completer for FuzzyCompleter { let query = self.query.get_window(); let num_filtered = format!("\x1b[33m{}\x1b[0m", self.filtered.len()); let num_candidates = format!("\x1b[33m{}\x1b[0m", self.candidates.len()); + let title = self.title.clone(); + let title_width = title.len() as u16; + let number_candidates = self.number_candidates; + let min_pad = self.candidates.len().to_string().len().saturating_add(1).max(6); + let max_height = self.max_height; let visible = self.get_window(); let mut rows: u16 = 0; let top_bar = format!( - "\n{}{} \x1b[1mComplete\x1b[0m {}{}", + "\n{}{} \x1b[1m{}\x1b[0m {}{}", Self::TOP_LEFT, Self::HOR_LINE, - Self::HOR_LINE.repeat(cols.saturating_sub(13) as usize), + title, + Self::HOR_LINE.repeat(cols.saturating_sub(title_width + 5) as usize), Self::TOP_RIGHT ); buf.push_str(&top_bar); @@ -972,24 +966,51 @@ impl Completer for FuzzyCompleter { buf.push_str(&sep_line_final); rows += 1; + let mut lines_drawn = 0; for (i, candidate) in visible.iter().enumerate() { + if lines_drawn >= max_height { + break; + } let selector = if i + offset == cursor_pos { Self::SELECTOR_HL } else { Self::SELECTOR_GRAY }; - let mut content = candidate.content.clone(); - let col_lim = cols.saturating_sub(3); - if calc_str_width(&content) > col_lim { - content.truncate(col_lim.saturating_sub(6) as usize); // ui bars + elipses length - content.push_str("..."); - } - let left = format!("{} {}{}\x1b[0m", Self::VERT_LINE, &selector, &content); - let cols_used = calc_str_width(&left); - let right_pad = " ".repeat(cols.saturating_sub(cols_used + 1) as usize); - let hl_cand_line = format!("{}{}{}", left, right_pad, Self::VERT_LINE); - buf.push_str(&hl_cand_line); - rows += 1; + let mut drew_number = false; + for line in candidate.content.trim_end().lines() { + if lines_drawn >= max_height { + break; + } + let mut line = line.trim_end().replace('\t', " "); + let col_lim = if number_candidates{ + cols.saturating_sub(3 + min_pad as u16) + } else { + cols.saturating_sub(3) + }; + if calc_str_width(&line) > col_lim { + line.truncate(col_lim.saturating_sub(6) as usize); + line.push_str("..."); + } + let left = if number_candidates { + if !drew_number { + let this_num = i + offset + 1; + let right_pad = " ".repeat(min_pad.saturating_sub(this_num.to_string().len())); + format!("{} {}\x1b[33m{}\x1b[39m{right_pad}{}\x1b[0m", Self::VERT_LINE, &selector,i + offset + 1, &line) + } else { + let right_pad = " ".repeat(min_pad); + format!("{} {}{}{}\x1b[0m", Self::VERT_LINE, &selector,right_pad, &line) + } + } else { + format!("{} {}{}\x1b[0m", Self::VERT_LINE, &selector, &line) + }; + let cols_used = calc_str_width(&left); + let right_pad = " ".repeat(cols.saturating_sub(cols_used + 1) as usize); + let hl_cand_line = format!("{}{}{}", left, right_pad, Self::VERT_LINE); + buf.push_str(&hl_cand_line); + rows += 1; + drew_number = true; + lines_drawn += 1; + } } let bot_bar = format!( @@ -1003,15 +1024,14 @@ impl Completer for FuzzyCompleter { buf.push_str(&bot_bar); rows += 1; - // Move cursor back up to the prompt line (skip: separator + candidates + bottom border) - let lines_below_prompt = rows.saturating_sub(2); // total rows minus top_bar and prompt + let lines_below_prompt = rows.saturating_sub(2); let cursor_in_window = self .query .linebuf .cursor .get() .saturating_sub(self.query.scroll_offset); - let cursor_col = (cursor_in_window + 4) as u16; // "| > ".len() == 4 + let cursor_col = (cursor_in_window + 4) as u16; write!(buf, "\x1b[{}A\r\x1b[{}C", lines_below_prompt, cursor_col).unwrap(); let new_layout = FuzzyLayout { @@ -1026,20 +1046,129 @@ impl Completer for FuzzyCompleter { Ok(()) } + + pub fn clear(&mut self, writer: &mut TermWriter) -> ShResult<()> { + if let Some(layout) = self.old_layout.take() { + let (new_cols, _) = get_win_size(*TTY_FILENO); + let total_cells = layout.rows as u32 * layout.cols as u32; + let physical_rows = if new_cols > 0 { + total_cells.div_ceil(new_cols as u32) as u16 + } else { + layout.rows + }; + let cursor_offset = layout.cols as u32 + layout.cursor_col as u32; + let cursor_phys_row = if new_cols > 0 { + (cursor_offset / new_cols as u32) as u16 + } else { + 1 + }; + let lines_below = physical_rows.saturating_sub(cursor_phys_row + 1); + + let gap_extra = if new_cols > 0 && layout.preceding_line_width > new_cols { + let wrap_rows = (layout.preceding_line_width as u32).div_ceil(new_cols as u32) as u16; + let cursor_wrap_row = layout.preceding_cursor_col / new_cols; + wrap_rows.saturating_sub(cursor_wrap_row + 1) + } else { + 0 + }; + + let mut buf = String::new(); + if lines_below > 0 { + write!(buf, "\x1b[{}B", lines_below).unwrap(); + } + for _ in 0..physical_rows { + buf.push_str("\x1b[2K\x1b[A"); + } + buf.push_str("\x1b[2K"); + for _ in 0..gap_extra { + buf.push_str("\x1b[A\x1b[2K"); + } + writer.flush_write(&buf)?; + } + Ok(()) + } +} + +impl Default for FuzzyCompleter { + fn default() -> Self { + Self { + completer: SimpleCompleter::default(), + selector: FuzzySelector::new("Complete"), + } + } +} + +impl Completer for FuzzyCompleter { + fn set_prompt_line_context(&mut self, line_width: u16, cursor_col: u16) { + self.selector.set_prompt_line_context(line_width, cursor_col); + } + fn reset_stay_active(&mut self) { + self.selector.reset_stay_active(); + } + fn get_completed_line(&self, _candidate: &str) -> String { + log::debug!("Getting completed line for candidate: {}", _candidate); + + let selected = self.selector.selected_candidate().unwrap_or_default(); + log::debug!("Selected candidate: {}", selected); + let (start, end) = self.completer.token_span; + log::debug!("Token span: ({}, {})", start, end); + let ret = format!( + "{}{}{}", + &self.completer.original_input[..start], + selected, + &self.completer.original_input[end..] + ); + log::debug!("Completed line: {}", ret); + ret + } + fn complete( + &mut self, + line: String, + cursor_pos: usize, + direction: i32, + ) -> ShResult> { + self.completer.complete(line, cursor_pos, direction)?; + let candidates: Vec<_> = self.completer.candidates.clone(); + if candidates.is_empty() { + self.completer.reset(); + self.selector.active = false; + return Ok(None); + } else if candidates.len() == 1 { + self.selector.filtered = candidates.into_iter().map(ScoredCandidate::from).collect(); + let selected = self.selector.filtered[0].content.clone(); + let completed = self.get_completed_line(&selected); + self.selector.active = false; + return Ok(Some(completed)); + } + self.selector.activate(candidates); + Ok(None) + } + + fn handle_key(&mut self, key: K) -> ShResult { + match self.selector.handle_key(key)? { + SelectorResponse::Accept(s) => Ok(CompResponse::Accept(s)), + SelectorResponse::Dismiss => Ok(CompResponse::Dismiss), + SelectorResponse::Consumed => Ok(CompResponse::Consumed), + } + } + fn clear(&mut self, writer: &mut TermWriter) -> ShResult<()> { + self.selector.clear(writer) + } + fn draw(&mut self, writer: &mut TermWriter) -> ShResult<()> { + self.selector.draw(writer) + } fn reset(&mut self) { - *self = Self::default(); + self.completer.reset(); + self.selector.reset(); } fn token_span(&self) -> (usize, usize) { self.completer.token_span() } fn is_active(&self) -> bool { - self.active + self.selector.is_active() } fn selected_candidate(&self) -> Option { - self - .filtered - .get(self.cursor.get()) - .map(|c| c.content.clone()) + self.selector.selected_candidate() } fn original_input(&self) -> &str { &self.completer.original_input diff --git a/src/readline/highlight.rs b/src/readline/highlight.rs index 49e6266..7801213 100644 --- a/src/readline/highlight.rs +++ b/src/readline/highlight.rs @@ -105,14 +105,17 @@ impl Highlighter { self.in_selection = false; } _ if self.only_hl_visual => { - self.output.push(ch); + if !is_marker(ch) { + self.output.push(ch); + } } markers::STRING_DQ_END | markers::STRING_SQ_END | markers::VAR_SUB_END | markers::CMD_SUB_END | markers::PROC_SUB_END - | markers::SUBSH_END => self.pop_style(), + | markers::SUBSH_END + | markers::HIST_EXP_END => self.pop_style(), markers::CMD_SEP | markers::RESET => self.clear_styles(), @@ -276,6 +279,23 @@ impl Highlighter { } self.last_was_reset = false; } + markers::HIST_EXP => { + let mut hist_exp = String::new(); + while let Some(ch) = input_chars.peek() { + if *ch == markers::HIST_EXP_END { + input_chars.next(); + break; + } else if markers::is_marker(*ch) { + input_chars.next(); + continue; + } + hist_exp.push(*ch); + input_chars.next(); + } + self.push_style(Style::Blue); + self.output.push_str(&hist_exp); + self.pop_style(); + } markers::VAR_SUB => { let mut var_sub = String::new(); while let Some(ch) = input_chars.peek() { diff --git a/src/readline/history.rs b/src/readline/history.rs index 1b44a3c..6d18e34 100644 --- a/src/readline/history.rs +++ b/src/readline/history.rs @@ -1,17 +1,10 @@ use std::{ - collections::HashSet, - env, - fmt::{Display, Write}, - fs::{self, OpenOptions}, - io::Write as IoWrite, - path::{Path, PathBuf}, - str::FromStr, - time::{Duration, SystemTime, UNIX_EPOCH}, + cmp::Ordering, collections::HashSet, env, fmt::{Display, Write}, fs::{self, OpenOptions}, io::Write as IoWrite, path::{Path, PathBuf}, str::FromStr, time::{Duration, SystemTime, UNIX_EPOCH} }; use crate::{ libsh::error::{ShErr, ShErrKind, ShResult}, - readline::linebuf::LineBuf, + readline::{complete::FuzzySelector, linebuf::LineBuf}, }; #[derive(Default, Clone, Copy, Debug)] @@ -207,6 +200,7 @@ pub struct History { pub pending: Option, // command, cursor_pos entries: Vec, search_mask: Vec, + pub fuzzy_finder: FuzzySelector, no_matches: bool, pub cursor: usize, //search_direction: Direction, @@ -232,6 +226,7 @@ impl History { Ok(Self { path, entries, + fuzzy_finder: FuzzySelector::new("History").number_candidates(true), pending: None, search_mask, no_matches: false, @@ -242,6 +237,20 @@ impl History { }) } + pub fn start_search(&mut self, initial: &str) -> Option { + if self.search_mask.is_empty() { + None + } else if self.search_mask.len() == 1 { + Some(self.search_mask[0].command().to_string()) + } else { + self.fuzzy_finder.set_query(initial.to_string()); + let raw_entries = self.search_mask.clone().into_iter() + .map(|ent| ent.command().to_string()); + self.fuzzy_finder.activate(raw_entries.collect()); + None + } + } + pub fn reset(&mut self) { self.search_mask = dedupe_entries(&self.entries); self.cursor = self.search_mask.len(); @@ -291,6 +300,36 @@ impl History { pub fn last_mut(&mut self) -> Option<&mut HistEntry> { self.entries.last_mut() } + pub fn last(&self) -> Option<&HistEntry> { + self.entries.last() + } + + pub fn resolve_hist_token(&self, token: &str) -> Option { + let token = token.strip_prefix('!').unwrap_or(token).to_string(); + if let Ok(num) = token.parse::() && num != 0 { + match num.cmp(&0) { + Ordering::Less => { + if num.unsigned_abs() > self.entries.len() as u32 { + return None; + } + + let rev_idx = self.entries.len() - num.unsigned_abs() as usize; + self.entries.get(rev_idx) + .map(|e| e.command().to_string()) + } + Ordering::Greater => { + self.entries.get(num as usize) + .map(|e| e.command().to_string()) + } + _ => unreachable!() + } + } else { + let mut rev_search = self.entries.iter(); + rev_search + .rfind(|e| e.command().starts_with(&token)) + .map(|e| e.command().to_string()) + } + } pub fn get_new_id(&self) -> u32 { let Some(ent) = self.entries.last() else { diff --git a/src/readline/keys.rs b/src/readline/keys.rs index 4815eb0..6b88a9d 100644 --- a/src/readline/keys.rs +++ b/src/readline/keys.rs @@ -114,6 +114,10 @@ impl KeyEvent { "Cannot convert unknown escape sequence to Vim key sequence".to_string(), )); } + KeyCode::ExMode => { + seq.push_str("CMD"); + needs_angle_bracket = true; + } KeyCode::Backspace => { seq.push_str("BS"); needs_angle_bracket = true; @@ -222,6 +226,9 @@ pub enum KeyCode { Right, Tab, Up, + + // weird stuff + ExMode, // keycode emitted by the byte alias in vim keymaps } bitflags::bitflags! { diff --git a/src/readline/linebuf.rs b/src/readline/linebuf.rs index b2fe6a5..a2b88e3 100644 --- a/src/readline/linebuf.rs +++ b/src/readline/linebuf.rs @@ -15,13 +15,11 @@ use crate::{ libsh::{error::ShResult, guards::var_ctx_guard}, parse::{ execute::exec_input, - lex::{LexFlags, LexStream, Tk, TkFlags, TkRule}, + lex::{LexFlags, LexStream, QuoteState, Tk, TkFlags, TkRule}, }, prelude::*, readline::{ - markers, - register::{RegisterContent, write_register}, - term::RawModeGuard, + history::History, markers, register::{RegisterContent, write_register}, term::RawModeGuard }, state::{VarFlags, VarKind, read_shopts, write_meta, write_vars}, }; @@ -299,6 +297,13 @@ impl ClampedUsize { let max = self.upper_bound(); self.value = (self.value + value).clamp(0, max) } + pub fn add_signed(&mut self, value: isize) { + if value.is_negative() { + self.sub(value.wrapping_abs() as usize); + } else { + self.add(value as usize); + } + } pub fn sub(&mut self, value: usize) { self.value = self.value.saturating_sub(value) } @@ -645,6 +650,10 @@ impl LineBuf { self.buffer.push_str(slice); self.update_graphemes(); } + pub fn insert_str_at_cursor(&mut self, slice: &str) { + let pos = self.index_byte_pos(self.cursor.get()); + self.insert_str_at(pos, slice); + } pub fn insert_at_cursor(&mut self, ch: char) { self.insert_at(self.cursor.get(), ch); } @@ -2893,7 +2902,7 @@ impl LineBuf { } } Verb::Insert(string) => { - self.push_str(&string); + self.insert_str_at_cursor(&string); let graphemes = string.graphemes(true).count(); log::debug!("Inserted string: {string:?}, graphemes: {graphemes}"); log::debug!("buffer after insert: {:?}", self.buffer); @@ -3317,6 +3326,73 @@ impl LineBuf { Ok(()) } + + pub fn attempt_history_expansion(&mut self, hist: &History) -> bool { + self.update_graphemes(); + let mut changes: Vec<(Range,String)> = vec![]; + let mut graphemes = self.buffer.grapheme_indices(true); + let mut qt_state = QuoteState::default(); + + while let Some((i,gr)) = graphemes.next() { + match gr { + "\\" => { + graphemes.next(); + } + "'" => qt_state.toggle_single(), + "\"" => qt_state.toggle_double(), + "!" if !qt_state.in_single() => { + let start = i; + match graphemes.next() { + Some((_,"!")) => { + // we have "!!", which expands to the previous command + if let Some(prev) = hist.last() { + let raw = prev.command(); + changes.push((start..start+2, raw.to_string())); + } + } + Some((_,"$")) => { + // we have "!$", which expands to the last word of the previous command + if let Some(prev) = hist.last() { + let raw = prev.command(); + if let Some(last_word) = raw.split_whitespace().last() { + changes.push((start..start+2, last_word.to_string())); + } + } + } + Some((j,gr)) if !is_whitespace(gr) => { + let mut end = j + gr.len(); + while let Some((k, gr2)) = graphemes.next() { + if is_whitespace(gr2) { break; } + end = k + gr2.len(); + } + let token = &self.buffer[j..end]; + let cmd = hist.resolve_hist_token(token).unwrap_or(token.into()); + changes.push((start..end, cmd)); + } + _ => { /* not a hist expansion */ } + } + } + _ => { /* carry on */ } + } + } + + let ret = !changes.is_empty(); + + let buf_len = self.grapheme_indices().len(); + + for (range,change) in changes.into_iter().rev() { + self.buffer.replace_range(range, &change); + } + self.update_graphemes(); + + let new_len = self.grapheme_indices().len(); + let delta = new_len as isize - buf_len as isize; + + self.cursor.set_max(new_len); + self.cursor.add_signed(delta); + ret + } + pub fn as_str(&self) -> &str { &self.buffer // FIXME: this will have to be fixed up later } diff --git a/src/readline/mod.rs b/src/readline/mod.rs index 16b63c2..37dd7a6 100644 --- a/src/readline/mod.rs +++ b/src/readline/mod.rs @@ -12,11 +12,11 @@ use crate::expand::expand_prompt; use crate::libsh::sys::TTY_FILENO; use crate::libsh::utils::AutoCmdVecUtils; use crate::parse::lex::{LexStream, QuoteState}; -use crate::readline::complete::FuzzyCompleter; +use crate::readline::complete::{FuzzyCompleter, SelectorResponse}; use crate::readline::term::{Pos, TermReader, calc_str_width}; use crate::readline::vimode::{ViEx, ViVerbatim}; use crate::state::{ - AutoCmdKind, ShellParam, VarFlags, VarKind, read_logic, read_shopts, write_meta, write_vars, + AutoCmdKind, ShellParam, VarFlags, VarKind, read_logic, read_shopts, with_vars, write_meta, write_vars }; use crate::{ libsh::error::ShResult, @@ -76,6 +76,8 @@ pub mod markers { pub const STRING_SQ_END: Marker = '\u{e115}'; pub const ESCAPE: Marker = '\u{e116}'; pub const GLOB: Marker = '\u{e117}'; + pub const HIST_EXP: Marker = '\u{e11c}'; + pub const HIST_EXP_END: Marker = '\u{e11d}'; // other pub const VISUAL_MODE_START: Marker = '\u{e118}'; @@ -409,11 +411,51 @@ impl ShedVi { // Process all available keys while let Some(key) = self.reader.read_key()? { - // If completer is active, delegate input to it - if self.completer.is_active() { + // If completer or history search are active, delegate input to it + if self.history.fuzzy_finder.is_active() { + self.print_line(false)?; + match self.history.fuzzy_finder.handle_key(key)? { + SelectorResponse::Accept(cmd) => { + let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistorySelect)); + + self.editor.set_buffer(cmd.to_string()); + self.editor.move_cursor_to_end(); + self.history.update_pending_cmd((self.editor.as_str(), self.editor.cursor.get())); + self.editor.set_hint(None); + self.history.fuzzy_finder.clear(&mut self.writer)?; + self.history.fuzzy_finder.reset(); + + with_vars([("_HIST_ENTRY".into(), cmd.clone())], || { + post_cmds.exec_with(&cmd); + }); + + write_vars(|v| v.set_var("SHED_VI_MODE", VarKind::Str(self.mode.report_mode().to_string()), VarFlags::NONE)).ok(); + self.prompt.refresh(); + self.needs_redraw = true; + continue; + } + SelectorResponse::Dismiss => { + let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistoryClose)); + post_cmds.exec(); + + self.editor.set_hint(None); + self.history.fuzzy_finder.clear(&mut self.writer)?; + write_vars(|v| v.set_var("SHED_VI_MODE", VarKind::Str(self.mode.report_mode().to_string()), VarFlags::NONE)).ok(); + self.prompt.refresh(); + self.needs_redraw = true; + continue; + } + SelectorResponse::Consumed => { + self.needs_redraw = true; + continue; + } + } + } else if self.completer.is_active() { self.print_line(false)?; match self.completer.handle_key(key.clone())? { CompResponse::Accept(candidate) => { + let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnCompletionSelect)); + let span_start = self.completer.token_span().0; let new_cursor = span_start + candidate.len(); let line = self.completer.get_completed_line(&candidate); @@ -432,12 +474,22 @@ impl ShedVi { self.completer.clear(&mut self.writer)?; self.needs_redraw = true; self.completer.reset(); + + with_vars([("_COMP_CANDIDATE".into(), candidate.clone())], || { + post_cmds.exec_with(&candidate); + }); + continue; } CompResponse::Dismiss => { + let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnCompletionCancel)); + post_cmds.exec(); + let hint = self.history.get_hint(); self.editor.set_hint(hint); self.completer.clear(&mut self.writer)?; + write_vars(|v| v.set_var("SHED_VI_MODE", VarKind::Str(self.mode.report_mode().to_string()), VarFlags::NONE)).ok(); + self.prompt.refresh(); self.completer.reset(); continue; } @@ -486,6 +538,9 @@ impl ShedVi { return Ok(event); } } + if !self.completer.is_active() && !self.history.fuzzy_finder.is_active() { + write_vars(|v| v.set_var("SHED_VI_MODE", VarKind::Str(self.mode.report_mode().to_string()), VarFlags::NONE)).ok(); + } // Redraw if we processed any input if self.needs_redraw { @@ -498,6 +553,7 @@ impl ShedVi { pub fn handle_key(&mut self, key: KeyEvent) -> ShResult> { if self.should_accept_hint(&key) { + log::debug!("Accepting hint on key {key:?} in mode {:?}", self.mode.report_mode()); self.editor.accept_hint(); if !self.history.at_pending() { self.history.reset_to_pending(); @@ -510,6 +566,12 @@ impl ShedVi { } if let KeyEvent(KeyCode::Tab, mod_keys) = key { + if self.editor.attempt_history_expansion(&self.history) { + // If history expansion occurred, don't attempt completion yet + // allow the user to see the expanded command and accept or edit it before completing + return Ok(None); + } + let direction = match mod_keys { ModKeys::SHIFT => -1, _ => 1, @@ -524,7 +586,14 @@ impl ShedVi { self.old_layout = None; } Ok(Some(line)) => { + let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnCompletionSelect)); + let cand = self.completer.selected_candidate().unwrap_or_default(); + with_vars([("_COMP_CANDIDATE".into(), cand.clone())], || { + post_cmds.exec_with(&cand); + }); + let span_start = self.completer.token_span().0; + let new_cursor = span_start + self .completer @@ -532,7 +601,7 @@ impl ShedVi { .map(|c| c.len()) .unwrap_or_default(); - self.editor.set_buffer(line); + self.editor.set_buffer(line.clone()); self.editor.cursor.set(new_cursor); if !self.history.at_pending() { @@ -543,10 +612,19 @@ impl ShedVi { .update_pending_cmd((self.editor.as_str(), self.editor.cursor.get())); let hint = self.history.get_hint(); self.editor.set_hint(hint); + + } Ok(None) => { + let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnCompletionStart)); + + post_cmds.exec(); + self.writer.send_bell().ok(); if self.completer.is_active() { + write_vars(|v| v.set_var("SHED_VI_MODE", VarKind::Str("COMPLETE".to_string()), VarFlags::NONE)).ok(); + self.prompt.refresh(); + self.needs_redraw = true; self.editor.set_hint(None); } } @@ -554,7 +632,34 @@ impl ShedVi { self.needs_redraw = true; return Ok(None); - } + } else if let KeyEvent(KeyCode::Char('R'), ModKeys::CTRL) = key { + let initial = self.editor.as_str(); + match self.history.start_search(initial) { + Some(entry) => { + let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistorySelect)); + with_vars([("_HIST_ENTRY".into(), entry.clone())], || { + post_cmds.exec_with(&entry); + }); + + self.editor.set_buffer(entry); + self.editor.move_cursor_to_end(); + self.history.update_pending_cmd((self.editor.as_str(), self.editor.cursor.get())); + self.editor.set_hint(None); + } + None => { + let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistoryOpen)); + post_cmds.exec(); + + self.writer.send_bell().ok(); + if self.history.fuzzy_finder.is_active() { + write_vars(|v| v.set_var("SHED_VI_MODE", VarKind::Str("SEARCH".to_string()), VarFlags::NONE)).ok(); + self.prompt.refresh(); + self.needs_redraw = true; + self.editor.set_hint(None); + } + } + } + } if let KeyEvent(KeyCode::Char('\\'), ModKeys::NONE) = key && !self.next_is_escaped @@ -586,6 +691,12 @@ impl ShedVi { && !self.editor.buffer.ends_with('\\') && (self.should_submit()? || !read_shopts(|o| o.prompt.linebreak_on_incomplete)) { + if self.editor.attempt_history_expansion(&self.history) { + // If history expansion occurred, don't submit yet + // allow the user to see the expanded command and accept or edit it before submitting + return Ok(None); + } + self.editor.set_hint(None); self.editor.cursor.set(self.editor.cursor_max()); self.print_line(true)?; @@ -747,6 +858,7 @@ impl ShedVi { let one_line = new_layout.end.row == 0; self.completer.clear(&mut self.writer)?; + self.history.fuzzy_finder.clear(&mut self.writer)?; if let Some(layout) = self.old_layout.as_ref() { self.writer.clear_rows(layout)?; @@ -837,6 +949,9 @@ impl ShedVi { .set_prompt_line_context(preceding_width, new_layout.cursor.col); self.completer.draw(&mut self.writer)?; + self.history.fuzzy_finder.set_prompt_line_context(preceding_width, new_layout.cursor.col); + self.history.fuzzy_finder.draw(&mut self.writer)?; + self.old_layout = Some(new_layout); self.needs_redraw = false; @@ -1529,6 +1644,35 @@ pub fn annotate_token(token: Tk) -> Vec<(usize, Marker)> { insertions.push((span_start + index, markers::GLOB)); } } + '!' if !qt_state.in_single() && cmd_sub_depth == 0 && proc_sub_depth == 0 => { + let bang_pos = index; + token_chars.next(); // consume the '!' + if let Some((_, next_ch)) = token_chars.peek() { + match next_ch { + '!' | '$' => { + // !! or !$ + token_chars.next(); + insertions.push((span_start + bang_pos, markers::HIST_EXP)); + insertions.push((span_start + bang_pos + 2, markers::HIST_EXP_END)); + } + c if c.is_ascii_alphanumeric() || *c == '-' => { + // !word, !-N, !N + let mut end_pos = bang_pos + 1; + while let Some((cur_i, wch)) = token_chars.peek() { + if wch.is_ascii_alphanumeric() || *wch == '_' || *wch == '-' { + end_pos = *cur_i + 1; + token_chars.next(); + } else { + break; + } + } + insertions.push((span_start + bang_pos, markers::HIST_EXP)); + insertions.push((span_start + end_pos, markers::HIST_EXP_END)); + } + _ => { /* lone ! before non-expansion char, ignore */ } + } + } + } _ => { token_chars.next(); // consume the char with no special handling } diff --git a/src/readline/term.rs b/src/readline/term.rs index ba874a6..1142ba8 100644 --- a/src/readline/term.rs +++ b/src/readline/term.rs @@ -69,7 +69,7 @@ pub fn get_win_size(fd: RawFd) -> (Col, Row) { } } -fn enumerate_lines(s: &str, left_pad: usize) -> String { +fn enumerate_lines(s: &str, left_pad: usize, show_numbers: bool) -> String { let total_lines = s.lines().count(); let max_num_len = total_lines.to_string().len(); s.lines() @@ -84,23 +84,15 @@ fn enumerate_lines(s: &str, left_pad: usize) -> String { // " 2 | " — num + padding + " | " let prefix_len = max_num_len + 3; // "N | " let trail_pad = left_pad.saturating_sub(prefix_len); - if i == total_lines - 1 { - // Don't add a newline to the last line - write!( - acc, - "\x1b[0m\x1b[90m{}{num} |\x1b[0m {}{ln}", - " ".repeat(num_pad), - " ".repeat(trail_pad), - ) - .unwrap(); + let prefix = if show_numbers { + format!("\x1b[0m\x1b[90m{}{num} |\x1b[0m ", " ".repeat(num_pad)) } else { - writeln!( - acc, - "\x1b[0m\x1b[90m{}{num} |\x1b[0m {}{ln}", - " ".repeat(num_pad), - " ".repeat(trail_pad), - ) - .unwrap(); + " ".repeat(prefix_len + 1).to_string() + }; + if i == total_lines - 1 { + write!(acc, "{prefix}{}{ln}", " ".repeat(trail_pad)).unwrap(); + } else { + writeln!(acc, "{prefix}{}{ln}", " ".repeat(trail_pad)).unwrap(); } } acc @@ -1013,7 +1005,8 @@ impl LineWriter for TermWriter { let multiline = line.contains('\n'); if multiline { let prompt_end = Layout::calc_pos(self.t_cols, prompt, Pos { col: 0, row: 0 }, 0, false); - let display_line = enumerate_lines(line, prompt_end.col as usize); + let show_numbers = read_shopts(|o| o.prompt.line_numbers); + let display_line = enumerate_lines(line, prompt_end.col as usize, show_numbers); self.buffer.push_str(&display_line); } else { self.buffer.push_str(line); diff --git a/src/readline/vimode/insert.rs b/src/readline/vimode/insert.rs index 5cb4867..3df06a2 100644 --- a/src/readline/vimode/insert.rs +++ b/src/readline/vimode/insert.rs @@ -54,6 +54,15 @@ impl ViMode for ViInsert { .set_motion(MotionCmd(1, Motion::ForwardChar)); self.register_and_return() } + E(K::ExMode, _) => { + Some(ViCmd { + register: Default::default(), + verb: Some(VerbCmd(1, Verb::ExMode)), + motion: None, + raw_seq: String::new(), + flags: Default::default(), + }) + } E(K::Char('W'), M::CTRL) => { self.pending_cmd.set_verb(VerbCmd(1, Verb::Delete)); self.pending_cmd.set_motion(MotionCmd( diff --git a/src/readline/vimode/normal.rs b/src/readline/vimode/normal.rs index 42fe439..49418a3 100644 --- a/src/readline/vimode/normal.rs +++ b/src/readline/vimode/normal.rs @@ -756,6 +756,15 @@ impl ViMode for ViNormal { raw_seq: "".into(), flags: self.flags(), }), + E(K::ExMode, _) => { + return Some(ViCmd { + register: Default::default(), + verb: Some(VerbCmd(1, Verb::ExMode)), + motion: None, + raw_seq: self.take_cmd(), + flags: self.flags(), + }); + } E(K::Char('A'), M::CTRL) => { let count = self .parse_count(&mut self.pending_seq.chars().peekable()) diff --git a/src/readline/vimode/replace.rs b/src/readline/vimode/replace.rs index 30c41ef..2bfb697 100644 --- a/src/readline/vimode/replace.rs +++ b/src/readline/vimode/replace.rs @@ -41,6 +41,15 @@ impl ViMode for ViReplace { .set_motion(MotionCmd(1, Motion::ForwardChar)); self.register_and_return() } + E(K::ExMode, _) => { + Some(ViCmd { + register: Default::default(), + verb: Some(VerbCmd(1, Verb::ExMode)), + motion: None, + raw_seq: String::new(), + flags: Default::default(), + }) + } E(K::Char('W'), M::CTRL) => { self.pending_cmd.set_verb(VerbCmd(1, Verb::Delete)); self.pending_cmd.set_motion(MotionCmd( diff --git a/src/readline/vimode/visual.rs b/src/readline/vimode/visual.rs index 87799a5..ededaac 100644 --- a/src/readline/vimode/visual.rs +++ b/src/readline/vimode/visual.rs @@ -614,6 +614,15 @@ impl ViMode for ViVisual { raw_seq: "".into(), flags: CmdFlags::empty(), }), + E(K::ExMode, _) => { + return Some(ViCmd { + register: Default::default(), + verb: Some(VerbCmd(1, Verb::ExMode)), + motion: None, + raw_seq: String::new(), + flags: Default::default(), + }); + } E(K::Char('A'), M::CTRL) => { let count = self .parse_count(&mut self.pending_seq.chars().peekable()) diff --git a/src/state.rs b/src/state.rs index 85abf5b..aefee3b 100644 --- a/src/state.rs +++ b/src/state.rs @@ -550,6 +550,12 @@ pub enum AutoCmdKind { PostPrompt, PreModeChange, PostModeChange, + OnHistoryOpen, + OnHistoryClose, + OnHistorySelect, + OnCompletionStart, + OnCompletionCancel, + OnCompletionSelect, OnExit, } @@ -565,6 +571,12 @@ impl Display for AutoCmdKind { Self::PostPrompt => write!(f, "post-prompt"), Self::PreModeChange => write!(f, "pre-mode-change"), Self::PostModeChange => write!(f, "post-mode-change"), + Self::OnHistoryOpen => write!(f, "on-history-open"), + Self::OnHistoryClose => write!(f, "on-history-close"), + Self::OnHistorySelect => write!(f, "on-history-select"), + Self::OnCompletionStart => write!(f, "on-completion-start"), + Self::OnCompletionCancel => write!(f, "on-completion-cancel"), + Self::OnCompletionSelect => write!(f, "on-completion-select"), Self::OnExit => write!(f, "on-exit"), } } @@ -583,6 +595,12 @@ impl FromStr for AutoCmdKind { "post-prompt" => Ok(Self::PostPrompt), "pre-mode-change" => Ok(Self::PreModeChange), "post-mode-change" => Ok(Self::PostModeChange), + "on-history-open" => Ok(Self::OnHistoryOpen), + "on-history-close" => Ok(Self::OnHistoryClose), + "on-history-select" => Ok(Self::OnHistorySelect), + "on-completion-start" => Ok(Self::OnCompletionStart), + "on-completion-cancel" => Ok(Self::OnCompletionCancel), + "on-completion-select" => Ok(Self::OnCompletionSelect), "on-exit" => Ok(Self::OnExit), _ => Err(ShErr::simple( ShErrKind::ParseErr,