Skip to main content

rpi_tui/
autocomplete.rs

1//! Autocomplete support for TUI.
2//!
3//! Provides autocomplete suggestions for file paths, commands, and custom items.
4
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, Mutex};
7
8use super::fuzzy::fuzzy_filter;
9
10/// Path delimiters for parsing.
11const PATH_DELIMITERS: &[char] = &[' ', '\t', '"', '\'', '='];
12
13/// Greatest char-boundary byte index in `s` that is `<= idx` (clamped to
14/// `s.len()`). Callers receive a `cursor` that may be a *character* count or a
15/// byte offset handed back from the editor; either way, slicing
16/// `&input[..cursor]` panics if `cursor` lands inside a multibyte char. Snap to
17/// the nearest preceding boundary so the slice is always sound.
18///
19/// A position that IS a char boundary (including the END of the string) is
20/// kept as-is — the old `take_while(b <= idx)` returned the START of the char
21/// at `idx`, so `end: cursor` came out one char short and Tab-accept kept the
22/// input's last char: typing "/mode" + Tab produced "/modele" instead of
23/// "/model".
24fn 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/// An autocomplete item.
37#[derive(Debug, Clone)]
38pub struct AutocompleteItem {
39    /// Display text.
40    pub text: String,
41    /// Display label (optional, defaults to text).
42    pub label: Option<String>,
43    /// Description.
44    pub description: Option<String>,
45    /// Whether this is a directory.
46    pub is_directory: bool,
47    /// Whether to insert a space after this item.
48    pub insert_space: bool,
49}
50
51impl AutocompleteItem {
52    /// Create a new autocomplete item.
53    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    /// Add a label.
64    pub fn with_label(mut self, label: &str) -> Self {
65        self.label = Some(label.to_string());
66        self
67    }
68
69    /// Add a description.
70    pub fn with_description(mut self, description: &str) -> Self {
71        self.description = Some(description.to_string());
72        self
73    }
74
75    /// Mark as directory.
76    pub fn as_directory(mut self) -> Self {
77        self.is_directory = true;
78        self
79    }
80
81    /// Set whether to insert space after.
82    pub fn with_insert_space(mut self, insert_space: bool) -> Self {
83        self.insert_space = insert_space;
84        self
85    }
86
87    /// Get the display text.
88    pub fn display_text(&self) -> &str {
89        self.label.as_ref().unwrap_or(&self.text)
90    }
91}
92
93/// Autocomplete suggestions result.
94#[derive(Debug, Clone)]
95pub struct AutocompleteSuggestions {
96    /// List of suggestions.
97    pub items: Vec<AutocompleteItem>,
98    /// Start position in the input.
99    pub start: usize,
100    /// End position in the input.
101    pub end: usize,
102    /// Whether this is a path completion.
103    pub is_path: bool,
104}
105
106/// Autocomplete provider trait.
107pub trait AutocompleteProvider: Send + Sync {
108    /// Get suggestions for the given input.
109    fn get_suggestions(&self, input: &str, cursor: usize) -> Option<AutocompleteSuggestions>;
110
111    /// Get the name of this provider.
112    fn name(&self) -> &str;
113}
114
115/// Slash command for command completion.
116#[derive(Debug, Clone)]
117pub struct SlashCommand {
118    /// Command name (including the slash).
119    pub name: String,
120    /// Description.
121    pub description: String,
122}
123
124/// File path autocomplete provider.
125pub struct FilePathAutocompleteProvider {
126    root_path: Option<PathBuf>,
127    max_results: usize,
128}
129
130impl FilePathAutocompleteProvider {
131    /// Create a new file path provider.
132    pub fn new() -> Self {
133        Self {
134            root_path: None,
135            max_results: 50,
136        }
137    }
138
139    /// Create a provider with a root path.
140    pub fn with_root(root: PathBuf) -> Self {
141        Self {
142            root_path: Some(root),
143            max_results: 50,
144        }
145    }
146
147    /// Set max results.
148    pub fn with_max_results(mut self, max: usize) -> Self {
149        self.max_results = max;
150        self
151    }
152
153    /// Extract the path prefix from input.
154    fn extract_path_prefix(&self, input: &str, cursor: usize) -> Option<(String, usize)> {
155        // Snap to a char boundary so the slices below can never land inside a
156        // multibyte char (callers may pass a char count, not a byte offset).
157        let cursor = snap_cursor(input, cursor);
158        // Find the start of the current path token
159        let before_cursor = &input[..cursor];
160
161        // Find the last path delimiter before cursor
162        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        // Check for @file reference
171        let token = &input[start..cursor];
172        if token.starts_with('@') {
173            // Return start AFTER the @ so the @ is preserved in text[..start]
174            return Some((token[1..].to_string(), start + 1));
175        } else if token.starts_with('"') {
176            // Quoted path
177            return Some((token[1..].to_string(), start + 1));
178        }
179
180        Some((token.to_string(), start))
181    }
182
183    /// Get completions for a path prefix.
184    fn get_path_completions(&self, prefix: &str) -> Vec<AutocompleteItem> {
185        let mut items = Vec::new();
186
187        // Determine the directory to search
188        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        // Read directory
201        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                    // Filter by prefix
207                    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
262/// Slash command autocomplete provider.
263pub struct SlashCommandAutocompleteProvider {
264    commands: Vec<SlashCommand>,
265}
266
267impl SlashCommandAutocompleteProvider {
268    /// Create a new slash command provider.
269    pub fn new(commands: Vec<SlashCommand>) -> Self {
270        Self { commands }
271    }
272
273    /// Create with default commands plus a set of dynamically-discovered slash
274    /// commands. The discovered commands (e.g. prompt-template names exposed as
275    /// `/expand`-style `/`-prefixed entries) are appended to the built-in set so
276    /// the built-in names win on a fuzzy tie and the discovered names appear as
277    /// additional suggestions. Mirrors pi's behavior where prompt-template
278    /// invocations (`/<name>`) surface alongside built-in slash commands in
279    /// `/`-autocomplete (`agent-session.ts:1124` + `expandPromptTemplate`).
280    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    /// Create with default commands.
287    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    /// Add a command.
314    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        // Check if we're at the start of a slash command
325        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
356/// Combined autocomplete provider that chains multiple providers.
357pub struct CombinedAutocompleteProvider {
358    providers: Vec<Arc<dyn AutocompleteProvider>>,
359}
360
361impl CombinedAutocompleteProvider {
362    /// Create a new combined provider.
363    pub fn new() -> Self {
364        Self {
365            providers: Vec::new(),
366        }
367    }
368
369    /// Add a provider.
370    pub fn add_provider(&mut self, provider: Arc<dyn AutocompleteProvider>) {
371        self.providers.push(provider);
372    }
373
374    /// Create with default providers.
375    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
406/// Global autocomplete manager.
407pub struct AutocompleteManager {
408    provider: Mutex<Option<Arc<dyn AutocompleteProvider>>>,
409}
410
411impl AutocompleteManager {
412    /// Create a new manager.
413    pub fn new() -> Self {
414        Self {
415            provider: Mutex::new(None),
416        }
417    }
418
419    /// Set the provider.
420    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    /// Get suggestions.
427    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        // Regression: the caret at the END of the input (what Tab-accept sees)
449        // snapped one char short, so `end` excluded the last char and accept
450        // kept it: "/mode" + Tab became "/modele" instead of "/model".
451        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        // Interior boundary + mid-multibyte snap down.
456        assert_eq!(snap_cursor("/mode", 2), 2);
457        assert_eq!(snap_cursor("你", 1), 0);
458        assert_eq!(snap_cursor("你", 3), 3);
459        // Out-of-range clamps to the end.
460        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}