use crossterm::{
cursor,
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
style::{Color, Print, SetForegroundColor, ResetColor},
terminal::{self, ClearType},
ExecutableCommand, QueueableCommand,
};
use std::io::{self, Write};
use super::commands::COMMANDS;
use unicode_width::UnicodeWidthChar;
pub enum ReadResult {
Line(String),
Interrupted,
Eof,
}
pub struct InputHistory {
entries: Vec<String>,
cursor: Option<usize>,
stash: String,
}
impl Default for InputHistory {
fn default() -> Self {
Self::new()
}
}
impl InputHistory {
pub fn new() -> Self {
Self {
entries: Vec::new(),
cursor: None,
stash: String::new(),
}
}
pub fn load_from_file(&mut self, path: &std::path::Path) {
if let Ok(content) = std::fs::read_to_string(path) {
for line in content.lines() {
let s = line.trim().to_string();
if !s.is_empty() {
self.entries.push(s);
}
}
}
}
pub fn save_to_file(&self, path: &std::path::Path) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let start = self.entries.len().saturating_sub(1000);
let content = self.entries[start..].join("\n");
let _ = std::fs::write(path, content);
}
pub fn push(&mut self, line: &str) {
if line.is_empty() {
return;
}
if self.entries.last().map(|s| s.as_str()) != Some(line) {
self.entries.push(line.to_string());
}
self.cursor = None;
self.stash = String::new();
}
pub fn up(&mut self, current_line: &str) -> Option<String> {
if self.entries.is_empty() {
return None;
}
match self.cursor {
None => {
self.stash = current_line.to_string();
self.cursor = Some(self.entries.len() - 1);
}
Some(0) => {
}
Some(i) => {
self.cursor = Some(i - 1);
}
}
self.cursor.map(|i| self.entries[i].clone())
}
pub fn down(&mut self) -> Option<String> {
match self.cursor {
None => None,
Some(i) if i + 1 >= self.entries.len() => {
self.cursor = None;
Some(self.stash.clone())
}
Some(i) => {
self.cursor = Some(i + 1);
Some(self.entries[i + 1].clone())
}
}
}
pub fn reset_nav(&mut self) {
self.cursor = None;
self.stash = String::new();
}
}
pub fn readline_with_suggestions(
prompt: &str,
history: &mut InputHistory,
working_dir: Option<&std::path::Path>,
) -> io::Result<ReadResult> {
terminal::enable_raw_mode()?;
let mut stdout = std::io::stdout();
let mut file_cache: Option<Vec<String>> = None;
print!("{}", prompt);
stdout.flush()?;
let mut buf: Vec<char> = Vec::new(); let mut cursor_pos: usize = 0; let mut suggestions: Vec<String> = Vec::new(); let mut selected: usize = 0; let mut prev_sug_count: usize = 0;
let result = loop {
let evt = event::read()?;
let key = match evt {
Event::Key(k) if k.kind == KeyEventKind::Press => k,
_ => continue,
};
match (key.code, key.modifiers) {
(KeyCode::Enter, _) => {
if !suggestions.is_empty() {
let chosen = suggestions[selected].clone();
buf = format!("{} ", chosen).chars().collect();
cursor_pos = buf.len();
prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
let no_args = chosen != "/undo";
if no_args {
erase_suggestions(&mut stdout, prev_sug_count)?;
suggestions.clear();
stdout.execute(Print("\r\n"))?;
history.reset_nav();
let line: String = buf.iter().collect();
let line = line.trim().to_string();
break ReadResult::Line(line);
} else {
erase_suggestions(&mut stdout, prev_sug_count)?;
suggestions.clear();
prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &[], 0, 0)?;
continue;
}
}
erase_suggestions(&mut stdout, prev_sug_count)?;
stdout.execute(Print("\r\n"))?;
history.reset_nav();
let line: String = buf.iter().collect();
let line = line.trim().to_string();
break ReadResult::Line(line);
}
(KeyCode::Char('d'), KeyModifiers::CONTROL) => {
if buf.is_empty() {
erase_suggestions(&mut stdout, prev_sug_count)?;
stdout.execute(Print("\r\n"))?;
break ReadResult::Eof;
}
if cursor_pos < buf.len() {
buf.remove(cursor_pos);
update_suggestions(&buf, &mut suggestions, &mut selected, working_dir, &mut file_cache);
prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
}
}
(KeyCode::Char('c'), KeyModifiers::CONTROL) => {
erase_suggestions(&mut stdout, prev_sug_count)?;
suggestions.clear();
stdout.execute(Print("\r\n"))?;
history.reset_nav();
buf.clear();
break ReadResult::Interrupted;
}
(KeyCode::Char('u'), KeyModifiers::CONTROL) => {
buf.clear();
cursor_pos = 0;
update_suggestions(&buf, &mut suggestions, &mut selected, working_dir, &mut file_cache);
prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
}
(KeyCode::Char('w'), KeyModifiers::CONTROL) => {
while cursor_pos > 0 && buf[cursor_pos - 1] == ' ' {
cursor_pos -= 1;
buf.remove(cursor_pos);
}
while cursor_pos > 0 && buf[cursor_pos - 1] != ' ' {
cursor_pos -= 1;
buf.remove(cursor_pos);
}
update_suggestions(&buf, &mut suggestions, &mut selected, working_dir, &mut file_cache);
prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
}
(KeyCode::Char('a'), KeyModifiers::CONTROL) | (KeyCode::Home, _) => {
cursor_pos = 0;
prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
}
(KeyCode::Char('e'), KeyModifiers::CONTROL) | (KeyCode::End, _) => {
cursor_pos = buf.len();
prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
}
(KeyCode::Left, _) => {
if cursor_pos > 0 {
cursor_pos -= 1;
prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
}
}
(KeyCode::Right, _) => {
if cursor_pos < buf.len() {
cursor_pos += 1;
prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
}
}
(KeyCode::Up, _) => {
if !suggestions.is_empty() {
selected = if selected == 0 {
suggestions.len() - 1
} else {
selected - 1
};
prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
} else {
let current: String = buf.iter().collect();
if let Some(entry) = history.up(¤t) {
buf = entry.chars().collect();
cursor_pos = buf.len();
update_suggestions(&buf, &mut suggestions, &mut selected, working_dir, &mut file_cache);
prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
}
}
}
(KeyCode::Down, _) => {
if !suggestions.is_empty() {
selected = if selected + 1 >= suggestions.len() {
0
} else {
selected + 1
};
prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
} else {
if let Some(entry) = history.down() {
buf = entry.chars().collect();
cursor_pos = buf.len();
update_suggestions(&buf, &mut suggestions, &mut selected, working_dir, &mut file_cache);
prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
}
}
}
(KeyCode::Tab, _) => {
if !suggestions.is_empty() {
let chosen = suggestions[selected].clone();
buf = format!("{} ", chosen).chars().collect();
cursor_pos = buf.len();
suggestions.clear();
erase_suggestions(&mut stdout, prev_sug_count)?;
prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &[], 0, 0)?;
}
}
(KeyCode::Esc, _) => {
if !suggestions.is_empty() {
suggestions.clear();
erase_suggestions(&mut stdout, prev_sug_count)?;
prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &[], 0, 0)?;
} else {
buf.clear();
cursor_pos = 0;
prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &[], 0, 0)?;
}
}
(KeyCode::Backspace, _) => {
if cursor_pos > 0 {
cursor_pos -= 1;
buf.remove(cursor_pos);
update_suggestions(&buf, &mut suggestions, &mut selected, working_dir, &mut file_cache);
prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
}
}
(KeyCode::Delete, _) => {
if cursor_pos < buf.len() {
buf.remove(cursor_pos);
update_suggestions(&buf, &mut suggestions, &mut selected, working_dir, &mut file_cache);
prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
}
}
(KeyCode::Char(ch), mods)
if mods.is_empty() || mods == KeyModifiers::SHIFT =>
{
buf.insert(cursor_pos, ch);
cursor_pos += 1;
update_suggestions(&buf, &mut suggestions, &mut selected, working_dir, &mut file_cache);
prev_sug_count = redraw_line(&mut stdout, prompt, &buf, cursor_pos, &suggestions, selected, prev_sug_count)?;
}
_ => {}
}
};
terminal::disable_raw_mode()?;
Ok(result)
}
fn update_suggestions(buf: &[char], suggestions: &mut Vec<String>, selected: &mut usize, working_dir: Option<&std::path::Path>, file_cache: &mut Option<Vec<String>>) {
let line: String = buf.iter().collect();
if line.starts_with('/') {
let new_sug: Vec<String> = COMMANDS
.iter()
.filter(|c| c.cmd.starts_with(line.as_str()))
.map(|c| c.cmd.to_string())
.collect();
if !new_sug.is_empty() {
if *selected >= new_sug.len() {
*selected = 0;
}
} else {
*selected = 0;
}
*suggestions = new_sug;
} else if let Some(at_idx) = line.rfind('@') {
let partial = &line[at_idx+1..];
if let (None, Some(wd)) = (file_cache.as_ref(), working_dir) {
let mut files = Vec::new();
fn walk(root: &std::path::Path, dir: &std::path::Path, depth: usize, files: &mut Vec<String>) {
if depth > 4 || files.len() >= 100 { return; }
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
let fname = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
if fname.starts_with('.') { continue; }
if fname == "target" || fname == "node_modules" || fname == ".git" { continue; }
if path.is_dir() {
walk(root, &path, depth+1, files);
} else if let Ok(rel) = path.strip_prefix(root) {
if let Some(rel_str) = rel.to_str() {
files.push(rel_str.replace("\\", "/"));
}
}
if files.len() >= 100 { break; }
}
}
}
walk(wd, wd, 0, &mut files);
*file_cache = Some(files);
}
let filtered = file_cache.as_ref().map(|cache| {
cache.iter()
.filter(|f| f.to_lowercase().contains(&partial.to_lowercase()))
.take(10)
.cloned()
.collect::<Vec<String>>()
}).unwrap_or_default();
if !filtered.is_empty() {
if *selected >= filtered.len() {
*selected = 0;
}
} else {
*selected = 0;
}
*suggestions = filtered;
} else {
suggestions.clear();
*selected = 0;
}
}
fn erase_suggestions(stdout: &mut impl Write, count: usize) -> io::Result<()> {
if count == 0 {
return Ok(());
}
for _ in 0..count {
stdout
.queue(cursor::MoveDown(1))?
.queue(terminal::Clear(ClearType::CurrentLine))?;
}
stdout.queue(cursor::MoveUp(count as u16))?;
stdout.flush()?;
Ok(())
}
fn redraw_line(
stdout: &mut impl Write,
prompt: &str,
buf: &[char],
cursor_pos: usize,
suggestions: &[String],
selected: usize,
prev_sug_count: usize,
) -> io::Result<usize> {
let line: String = buf.iter().collect();
stdout
.queue(cursor::MoveToColumn(0))?
.queue(terminal::Clear(ClearType::CurrentLine))?
.queue(Print(prompt))?
.queue(Print(&line))?;
erase_suggestions(stdout, prev_sug_count)?;
if !suggestions.is_empty() {
let max_cmd_len = suggestions.iter().map(|s| s.len()).max().unwrap_or(0);
for (i, cmd) in suggestions.iter().enumerate() {
stdout
.queue(Print("\r\n"))?
.queue(terminal::Clear(ClearType::CurrentLine))?;
let desc = if cmd.starts_with('/') {
COMMANDS
.iter()
.find(|c| c.cmd == cmd)
.map(|c| c.desc)
.unwrap_or("")
} else {
""
};
if i == selected {
stdout
.queue(SetForegroundColor(Color::White))?
.queue(Print(format!(" {:<width$} {}", cmd, desc, width = max_cmd_len)))?
.queue(ResetColor)?;
} else {
stdout
.queue(SetForegroundColor(Color::DarkGrey))?
.queue(Print(format!(" {:<width$} {}", cmd, desc, width = max_cmd_len)))?
.queue(ResetColor)?;
}
}
stdout.queue(cursor::MoveUp(suggestions.len() as u16))?;
}
let prompt_visible_width = console::measure_text_width(prompt);
let buf_display_width: usize = buf[..cursor_pos].iter().map(|c| c.width().unwrap_or(0)).sum();
let col = (prompt_visible_width + buf_display_width) as u16;
stdout.queue(cursor::MoveToColumn(col))?;
stdout.flush()?;
Ok(suggestions.len())
}