syncable_cli/agent/ui/
autocomplete.rs

1//! Autocomplete support for slash commands and file references using inquire
2//!
3//! Provides a custom Autocomplete implementation that shows:
4//! - Slash command suggestions when user types "/"
5//! - File path suggestions when user types "@"
6
7use crate::agent::commands::SLASH_COMMANDS;
8use inquire::autocompletion::{Autocomplete, Replacement};
9use std::path::PathBuf;
10
11/// Autocomplete provider for slash commands and file references
12/// Shows suggestions when user types "/" or "@" followed by characters
13#[derive(Clone)]
14pub struct SlashCommandAutocomplete {
15    /// Cache of filtered commands for current input
16    filtered_commands: Vec<&'static str>,
17    /// Project root for file searches
18    project_path: PathBuf,
19    /// Cache of file paths found
20    cached_files: Vec<String>,
21    /// Current autocomplete mode
22    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    /// Set the project path for file searches
49    pub fn with_project_path(mut self, path: PathBuf) -> Self {
50        self.project_path = path;
51        self
52    }
53
54    /// Find the @ trigger position in the input
55    fn find_at_trigger(&self, input: &str) -> Option<usize> {
56        // Find the last @ that starts a file reference
57        // It should be either at the start or after a space
58        for (i, c) in input.char_indices().rev() {
59            if c == '@' {
60                // Check if it's at the start or after a space
61                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    /// Extract the file filter from input after @
76    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            // Get everything until next space or end
80            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    /// Search for files matching a pattern
90    fn search_files(&mut self, filter: &str) -> Vec<String> {
91        let mut results = Vec::new();
92        let filter_lower = filter.to_lowercase();
93
94        // Walk directory tree (limited depth)
95        self.walk_dir(
96            &self.project_path.clone(),
97            &filter_lower,
98            &mut results,
99            0,
100            4,
101        );
102
103        // Sort by relevance (exact matches first, then by length)
104        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    /// Recursively walk directory for matching files
119    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        // Skip common non-relevant directories
132        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            // Skip hidden files/dirs (except .env, .gitignore, etc.)
154            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                // Get relative path from project root
167                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                // Match against filter
173                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        // Check for @ file reference trigger
187        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        // Check for / command trigger (only at start of input)
201        if input.starts_with('/') {
202            self.mode = AutocompleteMode::Command;
203            let filter = input.trim_start_matches('/').to_lowercase();
204
205            // Store the command names for use in get_completion
206            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            // Return formatted suggestions for display
220            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        // No trigger found
237        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                    // For file suggestions, replace the @filter part with the selected file
252                    if let Some(at_pos) = self.find_at_trigger(input) {
253                        let before_at = &input[..at_pos];
254                        // The suggestion is "@path/to/file", we want to insert it
255                        let new_input = format!("{}{} ", before_at, suggestion);
256                        return Ok(Replacement::Some(new_input));
257                    }
258                }
259                AutocompleteMode::Command => {
260                    // Extract just the command name - first word after the /
261                    // Format is: "/model        Select a different AI model"
262                    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        // @ in middle of word (like email) should not trigger
299        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}