1use std::path::{Path, PathBuf};
6use std::sync::{Arc, Mutex};
7
8use super::fuzzy::fuzzy_filter;
9
10const PATH_DELIMITERS: &[char] = &[' ', '\t', '"', '\'', '='];
12
13fn snap_cursor(s: &str, idx: usize) -> usize {
25 let idx = idx.min(s.len());
26 if s.is_char_boundary(idx) {
27 return idx;
28 }
29 s.char_indices()
30 .take_while(|(b, _)| *b < idx)
31 .last()
32 .map(|(b, _)| b)
33 .unwrap_or(0)
34}
35
36#[derive(Debug, Clone)]
38pub struct AutocompleteItem {
39 pub text: String,
41 pub label: Option<String>,
43 pub description: Option<String>,
45 pub is_directory: bool,
47 pub insert_space: bool,
49}
50
51impl AutocompleteItem {
52 pub fn new(text: &str) -> Self {
54 Self {
55 text: text.to_string(),
56 label: None,
57 description: None,
58 is_directory: false,
59 insert_space: true,
60 }
61 }
62
63 pub fn with_label(mut self, label: &str) -> Self {
65 self.label = Some(label.to_string());
66 self
67 }
68
69 pub fn with_description(mut self, description: &str) -> Self {
71 self.description = Some(description.to_string());
72 self
73 }
74
75 pub fn as_directory(mut self) -> Self {
77 self.is_directory = true;
78 self
79 }
80
81 pub fn with_insert_space(mut self, insert_space: bool) -> Self {
83 self.insert_space = insert_space;
84 self
85 }
86
87 pub fn display_text(&self) -> &str {
89 self.label.as_ref().unwrap_or(&self.text)
90 }
91}
92
93#[derive(Debug, Clone)]
95pub struct AutocompleteSuggestions {
96 pub items: Vec<AutocompleteItem>,
98 pub start: usize,
100 pub end: usize,
102 pub is_path: bool,
104}
105
106pub trait AutocompleteProvider: Send + Sync {
108 fn get_suggestions(&self, input: &str, cursor: usize) -> Option<AutocompleteSuggestions>;
110
111 fn name(&self) -> &str;
113}
114
115#[derive(Debug, Clone)]
117pub struct SlashCommand {
118 pub name: String,
120 pub description: String,
122}
123
124pub struct FilePathAutocompleteProvider {
126 root_path: Option<PathBuf>,
127 max_results: usize,
128}
129
130impl FilePathAutocompleteProvider {
131 pub fn new() -> Self {
133 Self {
134 root_path: None,
135 max_results: 50,
136 }
137 }
138
139 pub fn with_root(root: PathBuf) -> Self {
141 Self {
142 root_path: Some(root),
143 max_results: 50,
144 }
145 }
146
147 pub fn with_max_results(mut self, max: usize) -> Self {
149 self.max_results = max;
150 self
151 }
152
153 fn extract_path_prefix(&self, input: &str, cursor: usize) -> Option<(String, usize)> {
155 let cursor = snap_cursor(input, cursor);
158 let before_cursor = &input[..cursor];
160
161 let mut start = 0;
163 for (i, c) in before_cursor.char_indices().rev() {
164 if PATH_DELIMITERS.contains(&c) {
165 start = i + 1;
166 break;
167 }
168 }
169
170 let token = &input[start..cursor];
172 if token.starts_with('@') {
173 return Some((token[1..].to_string(), start + 1));
175 } else if token.starts_with('"') {
176 return Some((token[1..].to_string(), start + 1));
178 }
179
180 Some((token.to_string(), start))
181 }
182
183 fn get_path_completions(&self, prefix: &str) -> Vec<AutocompleteItem> {
185 let mut items = Vec::new();
186
187 let (dir, partial) = if prefix.contains('/') || prefix.contains('\\') {
189 let path = Path::new(prefix);
190 let parent = path.parent().unwrap_or(Path::new("."));
191 let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
192 (parent.to_path_buf(), file_name.to_string())
193 } else {
194 (
195 self.root_path.clone().unwrap_or_else(|| PathBuf::from(".")),
196 prefix.to_string(),
197 )
198 };
199
200 if let Ok(entries) = std::fs::read_dir(&dir) {
202 for entry in entries.take(self.max_results) {
203 if let Ok(entry) = entry {
204 let name = entry.file_name().to_string_lossy().to_string();
205
206 if !partial.is_empty()
208 && !name.to_lowercase().starts_with(&partial.to_lowercase())
209 {
210 continue;
211 }
212
213 let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
214 let text = if is_dir { format!("{}/", name) } else { name };
215
216 items.push(
217 AutocompleteItem::new(&text)
218 .as_directory()
219 .with_insert_space(!is_dir),
220 );
221 }
222 }
223 }
224
225 items
226 }
227}
228
229impl Default for FilePathAutocompleteProvider {
230 fn default() -> Self {
231 Self::new()
232 }
233}
234
235impl AutocompleteProvider for FilePathAutocompleteProvider {
236 fn get_suggestions(&self, input: &str, cursor: usize) -> Option<AutocompleteSuggestions> {
237 let cursor = snap_cursor(input, cursor);
238 let (prefix, start) = self.extract_path_prefix(input, cursor)?;
239
240 if prefix.is_empty() && !input[start..cursor].starts_with('@') {
241 return None;
242 }
243
244 let items = self.get_path_completions(&prefix);
245 if items.is_empty() {
246 return None;
247 }
248
249 Some(AutocompleteSuggestions {
250 items,
251 start,
252 end: cursor,
253 is_path: true,
254 })
255 }
256
257 fn name(&self) -> &str {
258 "filepath"
259 }
260}
261
262pub struct SlashCommandAutocompleteProvider {
264 commands: Vec<SlashCommand>,
265}
266
267impl SlashCommandAutocompleteProvider {
268 pub fn new(commands: Vec<SlashCommand>) -> Self {
270 Self { commands }
271 }
272
273 pub fn with_commands(discovered: Vec<SlashCommand>) -> Self {
281 let mut commands = Self::with_default_commands().commands;
282 commands.extend(discovered);
283 Self { commands }
284 }
285
286 pub fn with_default_commands() -> Self {
288 Self::new(vec![
289 SlashCommand {
290 name: "/help".to_string(),
291 description: "Show help information".to_string(),
292 },
293 SlashCommand {
294 name: "/clear".to_string(),
295 description: "Clear the conversation".to_string(),
296 },
297 SlashCommand {
298 name: "/exit".to_string(),
299 description: "Exit the application".to_string(),
300 },
301 SlashCommand {
302 name: "/model".to_string(),
303 description: "Change the model".to_string(),
304 },
305 SlashCommand {
306 name: "/context".to_string(),
307 description: "List discovered context files, skills, and prompt templates"
308 .to_string(),
309 },
310 ])
311 }
312
313 pub fn add_command(&mut self, command: SlashCommand) {
315 self.commands.push(command);
316 }
317}
318
319impl AutocompleteProvider for SlashCommandAutocompleteProvider {
320 fn get_suggestions(&self, input: &str, cursor: usize) -> Option<AutocompleteSuggestions> {
321 let cursor = snap_cursor(input, cursor);
322 let before_cursor = &input[..cursor];
323
324 if !before_cursor.starts_with('/') {
326 return None;
327 }
328
329 let query = &before_cursor[1..];
330 let items: Vec<AutocompleteItem> = fuzzy_filter(&self.commands, query, |cmd| &cmd.name)
331 .into_iter()
332 .map(|cmd| {
333 AutocompleteItem::new(&cmd.name)
334 .with_description(&cmd.description)
335 .with_insert_space(false)
336 })
337 .collect();
338
339 if items.is_empty() {
340 return None;
341 }
342
343 Some(AutocompleteSuggestions {
344 items,
345 start: 0,
346 end: cursor,
347 is_path: false,
348 })
349 }
350
351 fn name(&self) -> &str {
352 "slash_command"
353 }
354}
355
356pub struct CombinedAutocompleteProvider {
358 providers: Vec<Arc<dyn AutocompleteProvider>>,
359}
360
361impl CombinedAutocompleteProvider {
362 pub fn new() -> Self {
364 Self {
365 providers: Vec::new(),
366 }
367 }
368
369 pub fn add_provider(&mut self, provider: Arc<dyn AutocompleteProvider>) {
371 self.providers.push(provider);
372 }
373
374 pub fn with_defaults() -> Self {
376 let mut combined = Self::new();
377 combined.add_provider(Arc::new(
378 SlashCommandAutocompleteProvider::with_default_commands(),
379 ));
380 combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
381 combined
382 }
383}
384
385impl Default for CombinedAutocompleteProvider {
386 fn default() -> Self {
387 Self::new()
388 }
389}
390
391impl AutocompleteProvider for CombinedAutocompleteProvider {
392 fn get_suggestions(&self, input: &str, cursor: usize) -> Option<AutocompleteSuggestions> {
393 for provider in &self.providers {
394 if let Some(suggestions) = provider.get_suggestions(input, cursor) {
395 return Some(suggestions);
396 }
397 }
398 None
399 }
400
401 fn name(&self) -> &str {
402 "combined"
403 }
404}
405
406pub struct AutocompleteManager {
408 provider: Mutex<Option<Arc<dyn AutocompleteProvider>>>,
409}
410
411impl AutocompleteManager {
412 pub fn new() -> Self {
414 Self {
415 provider: Mutex::new(None),
416 }
417 }
418
419 pub fn set_provider(&self, provider: Arc<dyn AutocompleteProvider>) {
421 if let Ok(mut p) = self.provider.lock() {
422 *p = Some(provider);
423 }
424 }
425
426 pub fn get_suggestions(&self, input: &str, cursor: usize) -> Option<AutocompleteSuggestions> {
428 self.provider
429 .lock()
430 .ok()?
431 .as_ref()?
432 .get_suggestions(input, cursor)
433 }
434}
435
436impl Default for AutocompleteManager {
437 fn default() -> Self {
438 Self::new()
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 #[test]
447 fn snap_cursor_keeps_boundaries_including_end() {
448 assert_eq!(snap_cursor("/mode", 5), 5);
452 assert_eq!(snap_cursor("/mo", 3), 3);
453 assert_eq!(snap_cursor("/model", 6), 6);
454 assert_eq!(snap_cursor("abc", 3), 3);
455 assert_eq!(snap_cursor("/mode", 2), 2);
457 assert_eq!(snap_cursor("你", 1), 0);
458 assert_eq!(snap_cursor("你", 3), 3);
459 assert_eq!(snap_cursor("/mode", 99), 5);
461 }
462 #[test]
463 fn test_slash_command_provider() {
464 let provider = SlashCommandAutocompleteProvider::with_default_commands();
465 let suggestions = provider.get_suggestions("/he", 3);
466
467 assert!(suggestions.is_some());
468 let s = suggestions.unwrap();
469 assert!(!s.items.is_empty());
470 assert!(s.items[0].text.starts_with("/help"));
471 }
472
473 #[test]
474 fn test_autocomplete_item() {
475 let item = AutocompleteItem::new("test.txt")
476 .with_description("A test file")
477 .as_directory();
478
479 assert_eq!(item.text, "test.txt");
480 assert!(item.is_directory);
481 }
482}