use crate::agent::commands::SLASH_COMMANDS;
use inquire::autocompletion::{Autocomplete, Replacement};
use std::path::PathBuf;
#[derive(Clone)]
pub struct SlashCommandAutocomplete {
filtered_commands: Vec<&'static str>,
project_path: PathBuf,
cached_files: Vec<String>,
mode: AutocompleteMode,
}
#[derive(Clone, Debug, PartialEq)]
enum AutocompleteMode {
None,
Command,
File,
}
impl Default for SlashCommandAutocomplete {
fn default() -> Self {
Self::new()
}
}
impl SlashCommandAutocomplete {
pub fn new() -> Self {
Self {
filtered_commands: Vec::new(),
project_path: std::env::current_dir().unwrap_or_default(),
cached_files: Vec::new(),
mode: AutocompleteMode::None,
}
}
pub fn with_project_path(mut self, path: PathBuf) -> Self {
self.project_path = path;
self
}
fn find_at_trigger(&self, input: &str) -> Option<usize> {
for (i, c) in input.char_indices().rev() {
if c == '@' {
if i == 0
|| input
.chars()
.nth(i - 1)
.map(|c| c.is_whitespace())
.unwrap_or(false)
{
return Some(i);
}
}
}
None
}
fn extract_file_filter(&self, input: &str) -> Option<String> {
if let Some(at_pos) = self.find_at_trigger(input) {
let after_at = &input[at_pos + 1..];
let filter: String = after_at
.chars()
.take_while(|c| !c.is_whitespace())
.collect();
return Some(filter);
}
None
}
fn search_files(&mut self, filter: &str) -> Vec<String> {
let mut results = Vec::new();
let filter_lower = filter.to_lowercase();
self.walk_dir(
&self.project_path.clone(),
&filter_lower,
&mut results,
0,
4,
);
results.sort_by(|a, b| {
let a_exact = a.to_lowercase().contains(&filter_lower);
let b_exact = b.to_lowercase().contains(&filter_lower);
match (a_exact, b_exact) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.len().cmp(&b.len()),
}
});
results.truncate(8);
results
}
fn walk_dir(
&self,
dir: &PathBuf,
filter: &str,
results: &mut Vec<String>,
depth: usize,
max_depth: usize,
) {
if depth > max_depth || results.len() >= 20 {
return;
}
let skip_dirs = [
"node_modules",
".git",
"target",
"__pycache__",
".venv",
"venv",
"dist",
"build",
".next",
];
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
let file_name = entry.file_name().to_string_lossy().to_string();
if file_name.starts_with('.')
&& !file_name.starts_with(".env")
&& !file_name.starts_with(".git")
{
continue;
}
if path.is_dir() {
if !skip_dirs.contains(&file_name.as_str()) {
self.walk_dir(&path, filter, results, depth + 1, max_depth);
}
} else {
let rel_path = path
.strip_prefix(&self.project_path)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| file_name.clone());
if filter.is_empty()
|| rel_path.to_lowercase().contains(filter)
|| file_name.to_lowercase().contains(filter)
{
results.push(rel_path);
}
}
}
}
}
impl Autocomplete for SlashCommandAutocomplete {
fn get_suggestions(&mut self, input: &str) -> Result<Vec<String>, inquire::CustomUserError> {
if let Some(filter) = self.extract_file_filter(input) {
self.mode = AutocompleteMode::File;
self.cached_files = self.search_files(&filter);
let suggestions: Vec<String> = self
.cached_files
.iter()
.map(|f| format!("@{}", f))
.collect();
return Ok(suggestions);
}
if input.starts_with('/') {
self.mode = AutocompleteMode::Command;
let filter = input.trim_start_matches('/').to_lowercase();
self.filtered_commands = SLASH_COMMANDS
.iter()
.filter(|cmd| {
cmd.name.to_lowercase().starts_with(&filter)
|| cmd
.alias
.map(|a| a.to_lowercase().starts_with(&filter))
.unwrap_or(false)
})
.take(6)
.map(|cmd| cmd.name)
.collect();
let suggestions: Vec<String> = SLASH_COMMANDS
.iter()
.filter(|cmd| {
cmd.name.to_lowercase().starts_with(&filter)
|| cmd
.alias
.map(|a| a.to_lowercase().starts_with(&filter))
.unwrap_or(false)
})
.take(6)
.map(|cmd| format!("/{:<12} {}", cmd.name, cmd.description))
.collect();
return Ok(suggestions);
}
self.mode = AutocompleteMode::None;
self.filtered_commands.clear();
self.cached_files.clear();
Ok(vec![])
}
fn get_completion(
&mut self,
input: &str,
highlighted_suggestion: Option<String>,
) -> Result<Replacement, inquire::CustomUserError> {
if let Some(suggestion) = highlighted_suggestion {
match self.mode {
AutocompleteMode::File => {
if let Some(at_pos) = self.find_at_trigger(input) {
let before_at = &input[..at_pos];
let new_input = format!("{}{} ", before_at, suggestion);
return Ok(Replacement::Some(new_input));
}
}
AutocompleteMode::Command => {
if let Some(cmd_with_slash) = suggestion.split_whitespace().next() {
return Ok(Replacement::Some(cmd_with_slash.to_string()));
}
}
AutocompleteMode::None => {}
}
}
Ok(Replacement::None)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_find_at_trigger_at_start() {
let ac = SlashCommandAutocomplete::new();
assert_eq!(ac.find_at_trigger("@file"), Some(0));
}
#[test]
fn test_find_at_trigger_after_space() {
let ac = SlashCommandAutocomplete::new();
assert_eq!(ac.find_at_trigger("hello @file"), Some(6));
}
#[test]
fn test_find_at_trigger_no_trigger() {
let ac = SlashCommandAutocomplete::new();
assert_eq!(ac.find_at_trigger("hello world"), None);
}
#[test]
fn test_find_at_trigger_email_not_trigger() {
let ac = SlashCommandAutocomplete::new();
assert_eq!(ac.find_at_trigger("user@example.com"), None);
}
#[test]
fn test_extract_file_filter_basic() {
let ac = SlashCommandAutocomplete::new();
assert_eq!(ac.extract_file_filter("@src"), Some("src".to_string()));
}
#[test]
fn test_extract_file_filter_with_text_before() {
let ac = SlashCommandAutocomplete::new();
assert_eq!(
ac.extract_file_filter("read @main.rs"),
Some("main.rs".to_string())
);
}
#[test]
fn test_extract_file_filter_empty() {
let ac = SlashCommandAutocomplete::new();
assert_eq!(ac.extract_file_filter("@"), Some(String::new()));
}
#[test]
fn test_extract_file_filter_no_trigger() {
let ac = SlashCommandAutocomplete::new();
assert_eq!(ac.extract_file_filter("hello world"), None);
}
#[test]
fn test_autocomplete_mode_default() {
let ac = SlashCommandAutocomplete::new();
assert_eq!(ac.mode, AutocompleteMode::None);
}
}