Early implementation of fuzzy completion menu

This commit is contained in:
2026-03-02 01:54:23 -05:00
parent 6d2d94b6a7
commit a2b8fc203f
9 changed files with 469 additions and 72 deletions

View File

@@ -2,7 +2,7 @@ use std::{env, os::unix::fs::PermissionsExt, path::Path};
use ariadne::{Fmt, Span};
use crate::{builtin::BUILTINS, libsh::error::{ShErr, ShErrKind, ShResult, next_color}, parse::{NdRule, Node, execute::prepare_argv, lex::KEYWORDS}, state::{self, ShAlias, ShFunc, read_logic, read_vars}};
use crate::{builtin::BUILTINS, libsh::error::{ShErr, ShErrKind, ShResult, next_color}, parse::{NdRule, Node, execute::prepare_argv, lex::KEYWORDS}, state::{self, ShAlias, ShFunc, read_logic}};
pub fn type_builtin(node: Node) -> ShResult<()> {
let NdRule::Command {

View File

@@ -80,11 +80,6 @@ pub fn read_builtin(node: Node) -> ShResult<()> {
write(borrow_fd(STDOUT_FILENO), prompt.as_bytes())?;
}
log::info!(
"read_builtin: starting read with delim={}",
read_opts.delim as char
);
let input = if isatty(STDIN_FILENO)? {
// Restore default terminal settings
RawModeGuard::with_cooked_mode(|| {

View File

@@ -16,7 +16,7 @@ use crate::state::{
ArrIndex, LogTab, VarFlags, VarKind, read_jobs, read_logic, read_vars, write_jobs, write_meta,
write_vars,
};
use crate::{jobs, prelude::*};
use crate::prelude::*;
const PARAMETERS: [char; 7] = ['@', '*', '#', '$', '?', '!', '0'];
@@ -949,9 +949,6 @@ pub fn expand_cmd_sub(raw: &str) -> ShResult<String> {
}
};
// Reclaim terminal foreground in case child changed it
jobs::take_term()?;
match status {
WtStat::Exited(_, _) => Ok(io_buf.as_str()?.trim_end().to_string()),
_ => Err(ShErr::simple(ShErrKind::InternalErr, "Command sub failed")),

View File

@@ -332,11 +332,8 @@ impl JobTab {
self.fg.as_mut()
}
pub fn new_fg(&mut self, job: Job) -> ShResult<Vec<WtStat>> {
let pgid = job.pgid();
self.fg = Some(job);
attach_tty(pgid)?;
let statuses = self.fg.as_mut().unwrap().wait_pgrp()?;
attach_tty(getpgrp())?;
Ok(statuses)
}
pub fn fg_to_bg(&mut self, stat: WtStat) -> ShResult<()> {
@@ -354,7 +351,7 @@ impl JobTab {
pub fn bg_to_fg(&mut self, id: JobID) -> ShResult<()> {
let job = self.remove_job(id);
if let Some(job) = job {
wait_fg(job)?;
wait_fg(job, true)?;
}
Ok(())
}
@@ -828,13 +825,15 @@ pub fn wait_bg(id: JobID) -> ShResult<()> {
}
/// Waits on the current foreground job and updates the shell's last status code
pub fn wait_fg(job: Job) -> ShResult<()> {
pub fn wait_fg(job: Job, interactive: bool) -> ShResult<()> {
if job.children().is_empty() {
return Ok(()); // Nothing to do
}
let mut code = 0;
let mut was_stopped = false;
if interactive {
attach_tty(job.pgid())?;
}
disable_reaping();
defer! {
enable_reaping();
@@ -862,16 +861,18 @@ pub fn wait_fg(job: Job) -> ShResult<()> {
j.take_fg();
});
}
if interactive {
take_term()?;
}
set_status(code);
Ok(())
}
pub fn dispatch_job(job: Job, is_bg: bool) -> ShResult<()> {
pub fn dispatch_job(job: Job, is_bg: bool, interactive: bool) -> ShResult<()> {
if is_bg {
write_jobs(|j| j.insert_job(job, false))?;
} else {
wait_fg(job)?;
wait_fg(job, interactive)?;
}
Ok(())
}

View File

@@ -152,6 +152,7 @@ pub struct Dispatcher {
source_name: String,
pub io_stack: IoStack,
pub job_stack: JobStack,
fg_job: bool,
}
impl Dispatcher {
@@ -163,6 +164,7 @@ impl Dispatcher {
source_name,
io_stack: IoStack::new(),
job_stack: JobStack::new(),
fg_job: true,
}
}
pub fn begin_dispatch(&mut self) -> ShResult<()> {
@@ -660,6 +662,7 @@ impl Dispatcher {
let pipes_and_cmds = get_pipe_stack(cmds.len()).into_iter().zip(cmds);
let is_bg = pipeline.flags.contains(NdFlags::BACKGROUND);
self.fg_job = !is_bg && self.interactive;
let mut tty_attached = false;
for ((rpipe, wpipe), mut cmd) in pipes_and_cmds {
@@ -686,15 +689,16 @@ impl Dispatcher {
// Give the pipeline terminal control as soon as the first child
// establishes the PGID, so later children (e.g. nvim) don't get
// SIGTTOU when they try to modify terminal attributes.
if !tty_attached && !is_bg {
if let Some(pgid) = self.job_stack.curr_job_mut().unwrap().pgid() {
// Only for interactive (top-level) pipelines — command substitution
// and other non-interactive contexts must not steal the terminal.
if !tty_attached && !is_bg && self.interactive
&& let Some(pgid) = self.job_stack.curr_job_mut().unwrap().pgid() {
attach_tty(pgid).ok();
tty_attached = true;
}
}
}
let job = self.job_stack.finalize_job().unwrap();
dispatch_job(job, is_bg)?;
dispatch_job(job, is_bg, self.interactive)?;
Ok(())
}
fn exec_builtin(&mut self, cmd: Node) -> ShResult<()> {
@@ -866,17 +870,36 @@ impl Dispatcher {
let job = self.job_stack.curr_job_mut().unwrap();
let existing_pgid = job.pgid();
let fg_job = self.fg_job;
let child_logic = |pgid: Option<Pid>| -> ! {
// Put ourselves in the correct process group before exec.
// For the first child in a pipeline pgid is None, so we
// become our own group leader (setpgid(0,0)). For later
// children we join the leader's group.
let _ = setpgid(Pid::from_raw(0), pgid.unwrap_or(Pid::from_raw(0)));
let our_pgid = pgid.unwrap_or(Pid::from_raw(0));
let _ = setpgid(Pid::from_raw(0), our_pgid);
// For foreground jobs, take the terminal BEFORE resetting
// signals. SIGTTOU is still SIG_IGN (inherited from the shell),
// so tcsetpgrp won't stop us. This prevents a race
// where the child exec's and tries to read stdin before the
// parent has called tcsetpgrp — which would deliver SIGTTIN
// (now SIG_DFL after reset_signals) and stop the child.
if fg_job {
let tty_pgid = if our_pgid == Pid::from_raw(0) {
nix::unistd::getpid()
} else {
our_pgid
};
let _ = tcsetpgrp(
unsafe { BorrowedFd::borrow_raw(*crate::libsh::sys::TTY_FILENO) },
tty_pgid,
);
}
// Reset signal dispositions before exec. SIG_IGN is preserved
// across execvpe, so the shell's ignored SIGTTIN/SIGTTOU would
// leak into child processes and break programs like nvim that
// need default terminal-stop behavior.
// leak into child processes.
crate::signal::reset_signals();
let cmd = &exec_args.cmd.0;

View File

@@ -1,8 +1,9 @@
use std::{
collections::HashSet, fmt::Debug, path::PathBuf, sync::Arc,
collections::HashSet, fmt::{Write,Debug}, path::PathBuf, sync::Arc,
};
use nix::sys::signal::Signal;
use unicode_width::UnicodeWidthStr;
use crate::{
builtin::complete::{CompFlags, CompOptFlags, CompOpts},
@@ -16,8 +17,7 @@ use crate::{
lex::{self, LexFlags, Tk, TkRule, ends_with_unescaped},
},
readline::{
Marker, annotate_input_recursive,
markers::{self, is_marker},
Marker, annotate_input_recursive, keys::{KeyCode as C, KeyEvent as K, ModKeys as M}, linebuf::{ClampedUsize, LineBuf}, markers::{self, is_marker}, term::{LineWriter, TermWriter}, vimode::{ViInsert, ViMode}
},
state::{VarFlags, VarKind, read_jobs, read_logic, read_meta, read_vars, write_vars},
};
@@ -512,8 +512,322 @@ impl CompResult {
}
}
pub enum CompResponse {
Passthrough, // key falls through
Accept(String), // user accepted completion
Dismiss, // user canceled completion
Consumed // key was handled, but completion remains active
}
pub trait Completer {
fn complete(&mut self, line: String, cursor_pos: usize, direction: i32) -> ShResult<Option<String>>;
fn reset(&mut self);
fn is_active(&self) -> bool;
fn selected_candidate(&self) -> Option<String>;
fn token_span(&self) -> (usize, usize);
fn original_input(&self) -> &str;
fn draw(&mut self, writer: &mut TermWriter) -> ShResult<()>;
fn clear(&mut self, _writer: &mut TermWriter) -> ShResult<()> { Ok(()) }
fn handle_key(&mut self, key: K) -> ShResult<CompResponse>;
fn get_completed_line(&self, candidate: &str) -> String {
let (start, end) = self.token_span();
let orig = self.original_input();
format!("{}{}{}", &orig[..start], candidate, &orig[end..])
}
}
#[derive(Default, Debug, Clone)]
pub struct Completer {
pub struct ScoredCandidate {
content: String,
score: Option<i32>,
}
impl ScoredCandidate {
const BONUS_BOUNDARY: i32 = 10;
const BONUS_CONSECUTIVE: i32 = 8;
const BONUS_FIRST_CHAR: i32 = 5;
const PENALTY_GAP_START: i32 = 3;
const PENALTY_GAP_EXTEND: i32 = 1;
pub fn new(content: String) -> Self {
Self { content, score: None }
}
fn is_word_bound(prev: char, curr: char) -> bool {
match prev {
'/' | '_' | '-' | '.' | ' ' => true,
c if c.is_lowercase() && curr.is_uppercase() => true, // camelCase boundary
_ => false,
}
}
pub fn fuzzy_score(&mut self, other: &str) -> i32 {
if other.is_empty() {
self.score = Some(0);
return 0;
}
let query_chars: Vec<char> = other.chars().collect();
let content_chars: Vec<char> = self.content.chars().collect();
let mut indices = vec![];
let mut qi = 0;
for (ci, c_ch) in self.content.chars().enumerate() {
if qi < query_chars.len() && c_ch.eq_ignore_ascii_case(&query_chars[qi]) {
indices.push(ci);
qi += 1;
}
}
if indices.len() != query_chars.len() {
self.score = Some(i32::MIN);
return i32::MIN;
}
let mut score: i32 = 0;
for (i, &idx) in indices.iter().enumerate() {
if idx == 0 {
score += Self::BONUS_FIRST_CHAR;
}
if idx == 0 || Self::is_word_bound(content_chars[idx - 1], content_chars[idx]) {
score += Self::BONUS_BOUNDARY;
}
if i > 0 {
let gap = idx - indices[i - 1] - 1;
if gap == 0 {
score += Self::BONUS_CONSECUTIVE;
} else {
score -= Self::PENALTY_GAP_START + (gap as i32 - 1) * Self::PENALTY_GAP_EXTEND;
}
}
}
self.score = Some(score);
score
}
}
impl From<String> for ScoredCandidate {
fn from(content: String) -> Self {
Self { content, score: None }
}
}
#[derive(Debug, Clone)]
pub struct FuzzyLayout {
rows: u16
}
#[derive(Default, Debug, Clone)]
pub struct QueryEditor {
mode: ViInsert,
linebuf: LineBuf
}
impl QueryEditor {
pub fn clear(&mut self) {
self.linebuf = LineBuf::default();
self.mode = ViInsert::default();
}
pub fn handle_key(&mut self, key: K) -> ShResult<()> {
let Some(cmd) = self.mode.handle_key(key) else {
return Ok(())
};
self.linebuf.exec_cmd(cmd)
}
}
#[derive(Clone, Debug)]
pub struct FuzzyCompleter {
completer: SimpleCompleter,
query: QueryEditor,
filtered: Vec<ScoredCandidate>,
candidates: Vec<String>,
cursor: ClampedUsize,
old_layout: Option<FuzzyLayout>,
max_height: usize,
scroll_offset: usize,
active: bool
}
impl FuzzyCompleter {
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]
}
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);
}
if self.cursor.get() >= self.scroll_offset + height.saturating_sub(1) {
self.scroll_offset = self.cursor.ret_sub(height.saturating_sub(2));
}
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
.clone()
.into_iter()
.filter_map(|c| {
let mut sc = ScoredCandidate::new(c);
let score = sc.fuzzy_score(self.query.linebuf.as_str());
if score > i32::MIN {
Some(sc)
} else {
None
}
}).collect();
scored.sort_by_key(|sc| sc.score.unwrap_or(i32::MIN));
scored.reverse();
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,
}
}
}
impl Completer for FuzzyCompleter {
fn complete(&mut self, line: String, cursor_pos: usize, direction: i32) -> ShResult<Option<String>> {
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);
}
self.active = true;
self.candidates = candidates;
self.score_candidates();
self.completer.reset();
Ok(None) // FuzzyCompleter itself doesn't directly return a completed line, it manages the state of the filtered candidates and selection
}
fn handle_key(&mut self, key: K) -> ShResult<CompResponse> {
match key {
K(C::Esc, M::NONE) => {
self.active = false;
self.filtered.clear();
Ok(CompResponse::Dismiss)
}
K(C::Enter, M::NONE) => {
if let Some(selected) = self.filtered.get(self.cursor.get()).map(|c| c.content.clone()) {
self.active = false;
self.query.clear();
self.filtered.clear();
Ok(CompResponse::Accept(selected))
} else {
Ok(CompResponse::Passthrough)
}
}
K(C::Tab, M::SHIFT) |
K(C::Up, M::NONE) => {
self.cursor.sub(1);
self.update_scroll_offset();
Ok(CompResponse::Consumed)
}
K(C::Tab, M::NONE) |
K(C::Down, M::NONE) => {
self.cursor.add(1);
self.update_scroll_offset();
Ok(CompResponse::Consumed)
}
_ => {
self.query.handle_key(key)?;
self.score_candidates();
Ok(CompResponse::Consumed)
}
}
}
fn clear(&mut self, writer: &mut TermWriter) -> ShResult<()> {
if let Some(layout) = self.old_layout.take() {
let mut buf = String::new();
// Cursor is on the query line. Move down to the last candidate.
if layout.rows > 0 {
write!(buf, "\x1b[{}B", layout.rows).unwrap();
}
// Erase each line and move up, back to the query line
for _ in 0..layout.rows {
buf.push_str("\x1b[2K\x1b[A");
}
// Erase the query line, then move up to the prompt line
buf.push_str("\x1b[2K\x1b[A");
writer.flush_write(&buf)?;
}
Ok(())
}
fn draw(&mut self, writer: &mut TermWriter) -> ShResult<()> {
if !self.active {
return Ok(());
}
let mut buf = String::new();
let cursor_pos = self.cursor.get();
let offset = self.scroll_offset;
let query = self.query.linebuf.as_str().to_string();
let visible = self.get_window();
buf.push_str("\n\r> ");
buf.push_str(&query);
for (i, candidate) in visible.iter().enumerate() {
buf.push_str("\n\r");
if i + offset == cursor_pos {
buf.push_str("\x1b[7m");
buf.push_str(&candidate.content);
buf.push_str("\x1b[0m");
} else {
buf.push_str(&candidate.content);
}
}
let new_layout = FuzzyLayout {
rows: visible.len() as u16, // +1 for the query line
};
// Move cursor back up to the query line and position after "> " + query text
write!(buf, "\x1b[{}A\r\x1b[{}C", new_layout.rows, self.query.linebuf.as_str().width() + 2).unwrap();
writer.flush_write(&buf)?;
self.old_layout = Some(new_layout);
Ok(())
}
fn reset(&mut self) {
*self = Self::default();
}
fn token_span(&self) -> (usize, usize) {
self.completer.token_span()
}
fn is_active(&self) -> bool {
self.active
}
fn selected_candidate(&self) -> Option<String> {
self.filtered.get(self.cursor.get()).map(|c| c.content.clone())
}
fn original_input(&self) -> &str {
&self.completer.original_input
}
}
#[derive(Default, Debug, Clone)]
pub struct SimpleCompleter {
pub candidates: Vec<String>,
pub selected_idx: usize,
pub original_input: String,
@@ -523,7 +837,45 @@ pub struct Completer {
pub add_space: bool,
}
impl Completer {
impl Completer for SimpleCompleter {
fn complete(&mut self, line: String, cursor_pos: usize, direction: i32) -> ShResult<Option<String>> {
if self.active {
Ok(Some(self.cycle_completion(direction)))
} else {
self.start_completion(line, cursor_pos)
}
}
fn reset(&mut self) {
*self = Self::default();
}
fn is_active(&self) -> bool {
self.active
}
fn selected_candidate(&self) -> Option<String> {
self.candidates.get(self.selected_idx).cloned()
}
fn token_span(&self) -> (usize, usize) {
self.token_span
}
fn draw(&mut self, _writer: &mut TermWriter) -> ShResult<()> {
Ok(())
}
fn original_input(&self) -> &str {
&self.original_input
}
fn handle_key(&mut self, _key: K) -> ShResult<CompResponse> {
Ok(CompResponse::Passthrough)
}
}
impl SimpleCompleter {
pub fn new() -> Self {
Self::default()
}
@@ -569,6 +921,12 @@ impl Completer {
ctx.push(markers::ARG);
}
}
markers::RESET => {
if ctx.len() > 1 {
ctx.pop();
last_priority = 0;
}
}
_ => {}
},
_ => {
@@ -584,31 +942,6 @@ impl Completer {
(ctx, ctx_start)
}
pub fn reset(&mut self) {
self.candidates.clear();
self.selected_idx = 0;
self.original_input.clear();
self.token_span = (0, 0);
self.active = false;
}
pub fn complete(
&mut self,
line: String,
cursor_pos: usize,
direction: i32,
) -> ShResult<Option<String>> {
if self.active {
Ok(Some(self.cycle_completion(direction)))
} else {
self.start_completion(line, cursor_pos)
}
}
pub fn selected_candidate(&self) -> Option<String> {
self.candidates.get(self.selected_idx).cloned()
}
pub fn cycle_completion(&mut self, direction: i32) -> String {
if self.candidates.is_empty() {
return self.original_input.clone();
@@ -713,7 +1046,7 @@ impl Completer {
let cword = if let Some(pos) = relevant
.iter()
.position(|tk| cursor_pos >= tk.span.range().start && cursor_pos <= tk.span.range().end)
.position(|tk| cursor_pos >= tk.span.range().start && cursor_pos < tk.span.range().end)
{
pos
} else {
@@ -825,10 +1158,12 @@ impl Completer {
self.token_span = (cur_token.span.range().start, cur_token.span.range().end);
if token_start >= self.token_span.0 && token_start <= self.token_span.1 {
self.token_span.0 = token_start;
cur_token
.span
.set_range(self.token_span.0..self.token_span.1);
}
// If token contains '=', only complete after the '='
let token_str = cur_token.span.as_str();

View File

@@ -162,7 +162,7 @@ impl MotionKind {
}
}
#[derive(Default, Debug)]
#[derive(Default, Clone, Debug)]
pub struct Edit {
pub pos: usize,
pub cursor_pos: usize,
@@ -235,7 +235,7 @@ impl Edit {
pub struct ClampedUsize {
value: usize,
max: usize,
exclusive: bool,
pub exclusive: bool,
}
impl ClampedUsize {
@@ -317,7 +317,7 @@ impl ClampedUsize {
}
}
#[derive(Default, Debug)]
#[derive(Default, Clone, Debug)]
pub struct LineBuf {
pub buffer: String,
pub hint: Option<String>,
@@ -2817,6 +2817,9 @@ impl LineBuf {
for _ in 0..delta {
if self.grapheme_at(line_start).is_some_and(|gr| gr == "\t") {
self.remove(line_start);
if !self.cursor_at_max() {
self.cursor.sub(1);
}
}
}
}

View File

@@ -10,12 +10,13 @@ use crate::expand::expand_prompt;
use crate::libsh::sys::TTY_FILENO;
use crate::parse::lex::{LexStream, QuoteState};
use crate::prelude::*;
use crate::readline::complete::FuzzyCompleter;
use crate::readline::term::{Pos, calc_str_width};
use crate::state::{ShellParam, read_shopts};
use crate::{
libsh::error::ShResult,
parse::lex::{self, LexFlags, Tk, TkFlags, TkRule},
readline::{complete::Completer, highlight::Highlighter},
readline::{complete::{CompResponse, Completer}, highlight::Highlighter},
};
pub mod complete;
@@ -206,7 +207,7 @@ pub struct ShedVi {
pub prompt: Prompt,
pub highlighter: Highlighter,
pub completer: Completer,
pub completer: Box<dyn Completer>,
pub mode: Box<dyn ViMode>,
pub repeat_action: Option<CmdReplay>,
@@ -225,7 +226,7 @@ impl ShedVi {
reader: PollReader::new(),
writer: TermWriter::new(tty),
prompt,
completer: Completer::new(),
completer: Box::new(FuzzyCompleter::default()),
highlighter: Highlighter::new(),
mode: Box::new(ViInsert::new()),
old_layout: None,
@@ -319,6 +320,44 @@ 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() {
match self.completer.handle_key(key.clone())? {
CompResponse::Accept(candidate) => {
let span_start = self.completer.token_span().0;
let new_cursor = span_start + candidate.len();
let line = self.completer.get_completed_line(&candidate);
self.editor.set_buffer(line);
self.editor.cursor.set(new_cursor);
// Don't reset yet — clear() needs old_layout to erase the selector.
if !self.history.at_pending() {
self.history.reset_to_pending();
}
self
.history
.update_pending_cmd((self.editor.as_str(), self.editor.cursor.get()));
let hint = self.history.get_hint();
self.editor.set_hint(hint);
self.completer.clear(&mut self.writer)?;
self.needs_redraw = true;
continue;
}
CompResponse::Dismiss => {
self.completer.clear(&mut self.writer)?;
// Don't reset yet — clear() needs old_layout to erase the selector.
// The next print_line() will call clear(), then we can reset.
continue;
}
CompResponse::Consumed => {
/* just redraw */
self.needs_redraw = true;
continue;
}
CompResponse::Passthrough => { /* fall through to normal handling below */ }
}
}
if self.should_accept_hint(&key) {
self.editor.accept_hint();
if !self.history.at_pending() {
@@ -347,7 +386,7 @@ impl ShedVi {
self.old_layout = None;
}
Ok(Some(line)) => {
let span_start = self.completer.token_span.0;
let span_start = self.completer.token_span().0;
let new_cursor = span_start
+ self
.completer
@@ -556,6 +595,8 @@ impl ShedVi {
.unwrap_or_default() as usize;
let one_line = new_layout.end.row == 0;
self.completer.clear(&mut self.writer)?;
if let Some(layout) = self.old_layout.as_ref() {
self.writer.clear_rows(layout)?;
}
@@ -610,6 +651,8 @@ impl ShedVi {
self.writer.flush_write(&self.mode.cursor_style())?;
self.completer.draw(&mut self.writer)?;
self.old_layout = Some(new_layout);
self.needs_redraw = false;
Ok(())

View File

@@ -66,7 +66,7 @@ pub trait ViMode {
}
}
#[derive(Default, Debug)]
#[derive(Default, Clone, Debug)]
pub struct ViInsert {
cmds: Vec<ViCmd>,
pending_cmd: ViCmd,