syncable_cli/agent/ui/
autocomplete.rs1use inquire::autocompletion::{Autocomplete, Replacement};
8use crate::agent::commands::SLASH_COMMANDS;
9use std::path::PathBuf;
10
11#[derive(Clone)]
14pub struct SlashCommandAutocomplete {
15 filtered_commands: Vec<&'static str>,
17 project_path: PathBuf,
19 cached_files: Vec<String>,
21 mode: AutocompleteMode,
23}
24
25#[derive(Clone, Debug, PartialEq)]
26enum AutocompleteMode {
27 None,
28 Command,
29 File,
30}
31
32impl Default for SlashCommandAutocomplete {
33 fn default() -> Self {
34 Self::new()
35 }
36}
37
38impl SlashCommandAutocomplete {
39 pub fn new() -> Self {
40 Self {
41 filtered_commands: Vec::new(),
42 project_path: std::env::current_dir().unwrap_or_default(),
43 cached_files: Vec::new(),
44 mode: AutocompleteMode::None,
45 }
46 }
47
48 pub fn with_project_path(mut self, path: PathBuf) -> Self {
50 self.project_path = path;
51 self
52 }
53
54 fn find_at_trigger(&self, input: &str) -> Option<usize> {
56 for (i, c) in input.char_indices().rev() {
59 if c == '@' {
60 if i == 0 || input.chars().nth(i - 1).map(|c| c.is_whitespace()).unwrap_or(false) {
62 return Some(i);
63 }
64 }
65 }
66 None
67 }
68
69 fn extract_file_filter(&self, input: &str) -> Option<String> {
71 if let Some(at_pos) = self.find_at_trigger(input) {
72 let after_at = &input[at_pos + 1..];
73 let filter: String = after_at.chars().take_while(|c| !c.is_whitespace()).collect();
75 return Some(filter);
76 }
77 None
78 }
79
80 fn search_files(&mut self, filter: &str) -> Vec<String> {
82 let mut results = Vec::new();
83 let filter_lower = filter.to_lowercase();
84
85 self.walk_dir(&self.project_path.clone(), &filter_lower, &mut results, 0, 4);
87
88 results.sort_by(|a, b| {
90 let a_exact = a.to_lowercase().contains(&filter_lower);
91 let b_exact = b.to_lowercase().contains(&filter_lower);
92 match (a_exact, b_exact) {
93 (true, false) => std::cmp::Ordering::Less,
94 (false, true) => std::cmp::Ordering::Greater,
95 _ => a.len().cmp(&b.len()),
96 }
97 });
98
99 results.truncate(8);
100 results
101 }
102
103 fn walk_dir(&self, dir: &PathBuf, filter: &str, results: &mut Vec<String>, depth: usize, max_depth: usize) {
105 if depth > max_depth || results.len() >= 20 {
106 return;
107 }
108
109 let skip_dirs = ["node_modules", ".git", "target", "__pycache__", ".venv", "venv", "dist", "build", ".next"];
111
112 let entries = match std::fs::read_dir(dir) {
113 Ok(e) => e,
114 Err(_) => return,
115 };
116
117 for entry in entries.flatten() {
118 let path = entry.path();
119 let file_name = entry.file_name().to_string_lossy().to_string();
120
121 if file_name.starts_with('.') && !file_name.starts_with(".env") && !file_name.starts_with(".git") {
123 continue;
124 }
125
126 if path.is_dir() {
127 if !skip_dirs.contains(&file_name.as_str()) {
128 self.walk_dir(&path, filter, results, depth + 1, max_depth);
129 }
130 } else {
131 let rel_path = path.strip_prefix(&self.project_path)
133 .map(|p| p.to_string_lossy().to_string())
134 .unwrap_or_else(|_| file_name.clone());
135
136 if filter.is_empty() || rel_path.to_lowercase().contains(filter) || file_name.to_lowercase().contains(filter) {
138 results.push(rel_path);
139 }
140 }
141 }
142 }
143}
144
145impl Autocomplete for SlashCommandAutocomplete {
146 fn get_suggestions(&mut self, input: &str) -> Result<Vec<String>, inquire::CustomUserError> {
147 if let Some(filter) = self.extract_file_filter(input) {
149 self.mode = AutocompleteMode::File;
150 self.cached_files = self.search_files(&filter);
151
152 let suggestions: Vec<String> = self.cached_files
153 .iter()
154 .map(|f| format!("@{}", f))
155 .collect();
156
157 return Ok(suggestions);
158 }
159
160 if input.starts_with('/') {
162 self.mode = AutocompleteMode::Command;
163 let filter = input.trim_start_matches('/').to_lowercase();
164
165 self.filtered_commands = SLASH_COMMANDS.iter()
167 .filter(|cmd| {
168 cmd.name.to_lowercase().starts_with(&filter) ||
169 cmd.alias.map(|a| a.to_lowercase().starts_with(&filter)).unwrap_or(false)
170 })
171 .take(6)
172 .map(|cmd| cmd.name)
173 .collect();
174
175 let suggestions: Vec<String> = SLASH_COMMANDS.iter()
177 .filter(|cmd| {
178 cmd.name.to_lowercase().starts_with(&filter) ||
179 cmd.alias.map(|a| a.to_lowercase().starts_with(&filter)).unwrap_or(false)
180 })
181 .take(6)
182 .map(|cmd| format!("/{:<12} {}", cmd.name, cmd.description))
183 .collect();
184
185 return Ok(suggestions);
186 }
187
188 self.mode = AutocompleteMode::None;
190 self.filtered_commands.clear();
191 self.cached_files.clear();
192 Ok(vec![])
193 }
194
195 fn get_completion(
196 &mut self,
197 input: &str,
198 highlighted_suggestion: Option<String>,
199 ) -> Result<Replacement, inquire::CustomUserError> {
200 if let Some(suggestion) = highlighted_suggestion {
201 match self.mode {
202 AutocompleteMode::File => {
203 if let Some(at_pos) = self.find_at_trigger(input) {
205 let before_at = &input[..at_pos];
206 let new_input = format!("{}{} ", before_at, suggestion);
208 return Ok(Replacement::Some(new_input));
209 }
210 }
211 AutocompleteMode::Command => {
212 if let Some(cmd_with_slash) = suggestion.split_whitespace().next() {
215 return Ok(Replacement::Some(cmd_with_slash.to_string()));
216 }
217 }
218 AutocompleteMode::None => {}
219 }
220 }
221 Ok(Replacement::None)
222 }
223}