syncable_cli/agent/ui/
autocomplete.rs1use crate::agent::commands::SLASH_COMMANDS;
8use inquire::autocompletion::{Autocomplete, Replacement};
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
62 || input
63 .chars()
64 .nth(i - 1)
65 .map(|c| c.is_whitespace())
66 .unwrap_or(false)
67 {
68 return Some(i);
69 }
70 }
71 }
72 None
73 }
74
75 fn extract_file_filter(&self, input: &str) -> Option<String> {
77 if let Some(at_pos) = self.find_at_trigger(input) {
78 let after_at = &input[at_pos + 1..];
79 let filter: String = after_at
81 .chars()
82 .take_while(|c| !c.is_whitespace())
83 .collect();
84 return Some(filter);
85 }
86 None
87 }
88
89 fn search_files(&mut self, filter: &str) -> Vec<String> {
91 let mut results = Vec::new();
92 let filter_lower = filter.to_lowercase();
93
94 self.walk_dir(
96 &self.project_path.clone(),
97 &filter_lower,
98 &mut results,
99 0,
100 4,
101 );
102
103 results.sort_by(|a, b| {
105 let a_exact = a.to_lowercase().contains(&filter_lower);
106 let b_exact = b.to_lowercase().contains(&filter_lower);
107 match (a_exact, b_exact) {
108 (true, false) => std::cmp::Ordering::Less,
109 (false, true) => std::cmp::Ordering::Greater,
110 _ => a.len().cmp(&b.len()),
111 }
112 });
113
114 results.truncate(8);
115 results
116 }
117
118 fn walk_dir(
120 &self,
121 dir: &PathBuf,
122 filter: &str,
123 results: &mut Vec<String>,
124 depth: usize,
125 max_depth: usize,
126 ) {
127 if depth > max_depth || results.len() >= 20 {
128 return;
129 }
130
131 let skip_dirs = [
133 "node_modules",
134 ".git",
135 "target",
136 "__pycache__",
137 ".venv",
138 "venv",
139 "dist",
140 "build",
141 ".next",
142 ];
143
144 let entries = match std::fs::read_dir(dir) {
145 Ok(e) => e,
146 Err(_) => return,
147 };
148
149 for entry in entries.flatten() {
150 let path = entry.path();
151 let file_name = entry.file_name().to_string_lossy().to_string();
152
153 if file_name.starts_with('.')
155 && !file_name.starts_with(".env")
156 && !file_name.starts_with(".git")
157 {
158 continue;
159 }
160
161 if path.is_dir() {
162 if !skip_dirs.contains(&file_name.as_str()) {
163 self.walk_dir(&path, filter, results, depth + 1, max_depth);
164 }
165 } else {
166 let rel_path = path
168 .strip_prefix(&self.project_path)
169 .map(|p| p.to_string_lossy().to_string())
170 .unwrap_or_else(|_| file_name.clone());
171
172 if filter.is_empty()
174 || rel_path.to_lowercase().contains(filter)
175 || file_name.to_lowercase().contains(filter)
176 {
177 results.push(rel_path);
178 }
179 }
180 }
181 }
182}
183
184impl Autocomplete for SlashCommandAutocomplete {
185 fn get_suggestions(&mut self, input: &str) -> Result<Vec<String>, inquire::CustomUserError> {
186 if let Some(filter) = self.extract_file_filter(input) {
188 self.mode = AutocompleteMode::File;
189 self.cached_files = self.search_files(&filter);
190
191 let suggestions: Vec<String> = self
192 .cached_files
193 .iter()
194 .map(|f| format!("@{}", f))
195 .collect();
196
197 return Ok(suggestions);
198 }
199
200 if input.starts_with('/') {
202 self.mode = AutocompleteMode::Command;
203 let filter = input.trim_start_matches('/').to_lowercase();
204
205 self.filtered_commands = SLASH_COMMANDS
207 .iter()
208 .filter(|cmd| {
209 cmd.name.to_lowercase().starts_with(&filter)
210 || cmd
211 .alias
212 .map(|a| a.to_lowercase().starts_with(&filter))
213 .unwrap_or(false)
214 })
215 .take(6)
216 .map(|cmd| cmd.name)
217 .collect();
218
219 let suggestions: Vec<String> = SLASH_COMMANDS
221 .iter()
222 .filter(|cmd| {
223 cmd.name.to_lowercase().starts_with(&filter)
224 || cmd
225 .alias
226 .map(|a| a.to_lowercase().starts_with(&filter))
227 .unwrap_or(false)
228 })
229 .take(6)
230 .map(|cmd| format!("/{:<12} {}", cmd.name, cmd.description))
231 .collect();
232
233 return Ok(suggestions);
234 }
235
236 self.mode = AutocompleteMode::None;
238 self.filtered_commands.clear();
239 self.cached_files.clear();
240 Ok(vec![])
241 }
242
243 fn get_completion(
244 &mut self,
245 input: &str,
246 highlighted_suggestion: Option<String>,
247 ) -> Result<Replacement, inquire::CustomUserError> {
248 if let Some(suggestion) = highlighted_suggestion {
249 match self.mode {
250 AutocompleteMode::File => {
251 if let Some(at_pos) = self.find_at_trigger(input) {
253 let before_at = &input[..at_pos];
254 let new_input = format!("{}{} ", before_at, suggestion);
256 return Ok(Replacement::Some(new_input));
257 }
258 }
259 AutocompleteMode::Command => {
260 if let Some(cmd_with_slash) = suggestion.split_whitespace().next() {
263 return Ok(Replacement::Some(cmd_with_slash.to_string()));
264 }
265 }
266 AutocompleteMode::None => {}
267 }
268 }
269 Ok(Replacement::None)
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn test_find_at_trigger_at_start() {
279 let ac = SlashCommandAutocomplete::new();
280 assert_eq!(ac.find_at_trigger("@file"), Some(0));
281 }
282
283 #[test]
284 fn test_find_at_trigger_after_space() {
285 let ac = SlashCommandAutocomplete::new();
286 assert_eq!(ac.find_at_trigger("hello @file"), Some(6));
287 }
288
289 #[test]
290 fn test_find_at_trigger_no_trigger() {
291 let ac = SlashCommandAutocomplete::new();
292 assert_eq!(ac.find_at_trigger("hello world"), None);
293 }
294
295 #[test]
296 fn test_find_at_trigger_email_not_trigger() {
297 let ac = SlashCommandAutocomplete::new();
298 assert_eq!(ac.find_at_trigger("user@example.com"), None);
300 }
301
302 #[test]
303 fn test_extract_file_filter_basic() {
304 let ac = SlashCommandAutocomplete::new();
305 assert_eq!(ac.extract_file_filter("@src"), Some("src".to_string()));
306 }
307
308 #[test]
309 fn test_extract_file_filter_with_text_before() {
310 let ac = SlashCommandAutocomplete::new();
311 assert_eq!(
312 ac.extract_file_filter("read @main.rs"),
313 Some("main.rs".to_string())
314 );
315 }
316
317 #[test]
318 fn test_extract_file_filter_empty() {
319 let ac = SlashCommandAutocomplete::new();
320 assert_eq!(ac.extract_file_filter("@"), Some(String::new()));
321 }
322
323 #[test]
324 fn test_extract_file_filter_no_trigger() {
325 let ac = SlashCommandAutocomplete::new();
326 assert_eq!(ac.extract_file_filter("hello world"), None);
327 }
328
329 #[test]
330 fn test_autocomplete_mode_default() {
331 let ac = SlashCommandAutocomplete::new();
332 assert_eq!(ac.mode, AutocompleteMode::None);
333 }
334}