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 inquire::autocompletion::{Autocomplete, Replacement};
8use crate::agent::commands::SLASH_COMMANDS;
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 || 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    /// Extract the file filter from input after @
70    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            // Get everything until next space or end
74            let filter: String = after_at.chars().take_while(|c| !c.is_whitespace()).collect();
75            return Some(filter);
76        }
77        None
78    }
79
80    /// Search for files matching a pattern
81    fn search_files(&mut self, filter: &str) -> Vec<String> {
82        let mut results = Vec::new();
83        let filter_lower = filter.to_lowercase();
84
85        // Walk directory tree (limited depth)
86        self.walk_dir(&self.project_path.clone(), &filter_lower, &mut results, 0, 4);
87
88        // Sort by relevance (exact matches first, then by length)
89        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    /// Recursively walk directory for matching files
104    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        // Skip common non-relevant directories
110        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            // Skip hidden files/dirs (except .env, .gitignore, etc.)
122            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                // Get relative path from project root
132                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                // Match against filter
137                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        // Check for @ file reference trigger
148        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        // Check for / command trigger (only at start of input)
161        if input.starts_with('/') {
162            self.mode = AutocompleteMode::Command;
163            let filter = input.trim_start_matches('/').to_lowercase();
164
165            // Store the command names for use in get_completion
166            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            // Return formatted suggestions for display
176            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        // No trigger found
189        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                    // For file suggestions, replace the @filter part with the selected file
204                    if let Some(at_pos) = self.find_at_trigger(input) {
205                        let before_at = &input[..at_pos];
206                        // The suggestion is "@path/to/file", we want to insert it
207                        let new_input = format!("{}{} ", before_at, suggestion);
208                        return Ok(Replacement::Some(new_input));
209                    }
210                }
211                AutocompleteMode::Command => {
212                    // Extract just the command name - first word after the /
213                    // Format is: "/model        Select a different AI model"
214                    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}