syncable_cli/agent/ui/
input.rs

1//! Custom input handler with Claude Code-style @ file picker
2//!
3//! Provides:
4//! - Real-time inline file suggestions when typing @
5//! - Arrow key navigation in dropdown
6//! - **Enter to SELECT suggestion** (not submit)
7//! - Enter to SUBMIT only when no suggestions are active
8//! - Support for multiple @ file references
9
10use crate::agent::commands::SLASH_COMMANDS;
11use crate::agent::ui::colors::ansi;
12use crossterm::{
13    cursor::{self, MoveToColumn, MoveUp},
14    event::{self, Event, KeyCode, KeyModifiers},
15    execute,
16    terminal::{self, Clear, ClearType},
17};
18use std::io::{self, Write};
19use std::path::PathBuf;
20
21/// Result of reading user input
22pub enum InputResult {
23    /// User submitted text (Enter with no picker open)
24    Submit(String),
25    /// User cancelled (Ctrl+C or Escape with no picker)
26    Cancel,
27    /// User wants to exit
28    Exit,
29}
30
31/// Suggestion item
32#[derive(Clone)]
33struct Suggestion {
34    display: String,
35    value: String,
36    is_dir: bool,
37}
38
39/// Input state
40struct InputState {
41    /// Current input text
42    text: String,
43    /// Cursor position in text (character index)
44    cursor: usize,
45    /// Current suggestions
46    suggestions: Vec<Suggestion>,
47    /// Selected suggestion index (-1 = none selected)
48    selected: i32,
49    /// Whether suggestions dropdown is visible
50    showing_suggestions: bool,
51    /// Start position of current completion (@ position)
52    completion_start: Option<usize>,
53    /// Project path for file searches
54    project_path: PathBuf,
55    /// Number of lines rendered for suggestions (for cleanup)
56    rendered_lines: usize,
57}
58
59impl InputState {
60    fn new(project_path: PathBuf) -> Self {
61        Self {
62            text: String::new(),
63            cursor: 0,
64            suggestions: Vec::new(),
65            selected: -1,
66            showing_suggestions: false,
67            completion_start: None,
68            project_path,
69            rendered_lines: 0,
70        }
71    }
72
73    /// Insert character at cursor
74    fn insert_char(&mut self, c: char) {
75        // Insert at cursor position
76        let byte_pos = self.char_to_byte_pos(self.cursor);
77        self.text.insert(byte_pos, c);
78        self.cursor += 1;
79
80        // Check if we should trigger completion
81        if c == '@' {
82            let valid_trigger = self.cursor == 1 ||
83                self.text.chars().nth(self.cursor - 2).map(|c| c.is_whitespace()).unwrap_or(false);
84            if valid_trigger {
85                self.completion_start = Some(self.cursor - 1);
86                self.refresh_suggestions();
87            }
88        } else if c == '/' && self.cursor == 1 {
89            // Slash command at start
90            self.completion_start = Some(0);
91            self.refresh_suggestions();
92        } else if c.is_whitespace() {
93            // Space closes completion
94            self.close_suggestions();
95        } else if self.completion_start.is_some() {
96            // Continue filtering
97            self.refresh_suggestions();
98        }
99    }
100
101    /// Delete character before cursor
102    fn backspace(&mut self) {
103        if self.cursor > 0 {
104            let byte_pos = self.char_to_byte_pos(self.cursor - 1);
105            let next_byte_pos = self.char_to_byte_pos(self.cursor);
106            self.text.replace_range(byte_pos..next_byte_pos, "");
107            self.cursor -= 1;
108
109            // Check if we deleted the @ trigger
110            if let Some(start) = self.completion_start {
111                if self.cursor <= start {
112                    self.close_suggestions();
113                } else {
114                    self.refresh_suggestions();
115                }
116            }
117        }
118    }
119
120    /// Convert character position to byte position
121    fn char_to_byte_pos(&self, char_pos: usize) -> usize {
122        self.text.char_indices()
123            .nth(char_pos)
124            .map(|(i, _)| i)
125            .unwrap_or(self.text.len())
126    }
127
128    /// Get the current filter text (after @ or /)
129    fn get_filter(&self) -> Option<String> {
130        self.completion_start.map(|start| {
131            let filter_start = start + 1; // Skip the @ or /
132            if filter_start <= self.cursor {
133                self.text.chars().skip(filter_start).take(self.cursor - filter_start).collect()
134            } else {
135                String::new()
136            }
137        })
138    }
139
140    /// Refresh suggestions based on current filter
141    fn refresh_suggestions(&mut self) {
142        let filter = self.get_filter().unwrap_or_default();
143        let trigger = self.completion_start
144            .and_then(|pos| self.text.chars().nth(pos));
145
146        self.suggestions = match trigger {
147            Some('@') => self.search_files(&filter),
148            Some('/') => self.search_commands(&filter),
149            _ => Vec::new(),
150        };
151
152        self.showing_suggestions = !self.suggestions.is_empty();
153        self.selected = if self.showing_suggestions { 0 } else { -1 };
154    }
155
156    /// Search for files matching filter
157    fn search_files(&self, filter: &str) -> Vec<Suggestion> {
158        let mut results = Vec::new();
159        let filter_lower = filter.to_lowercase();
160
161        self.walk_dir(&self.project_path.clone(), &filter_lower, &mut results, 0, 4);
162
163        // Sort: directories first, then by path length
164        results.sort_by(|a, b| {
165            match (a.is_dir, b.is_dir) {
166                (true, false) => std::cmp::Ordering::Less,
167                (false, true) => std::cmp::Ordering::Greater,
168                _ => a.value.len().cmp(&b.value.len()),
169            }
170        });
171
172        results.truncate(8);
173        results
174    }
175
176    /// Walk directory tree for matching files
177    fn walk_dir(&self, dir: &PathBuf, filter: &str, results: &mut Vec<Suggestion>, depth: usize, max_depth: usize) {
178        if depth > max_depth || results.len() >= 20 {
179            return;
180        }
181
182        let skip_dirs = ["node_modules", ".git", "target", "__pycache__", ".venv", "venv", "dist", "build", ".next"];
183
184        let entries = match std::fs::read_dir(dir) {
185            Ok(e) => e,
186            Err(_) => return,
187        };
188
189        for entry in entries.flatten() {
190            let path = entry.path();
191            let file_name = entry.file_name().to_string_lossy().to_string();
192
193            // Skip hidden files (except some)
194            if file_name.starts_with('.') && !file_name.starts_with(".env") && file_name != ".gitignore" {
195                continue;
196            }
197
198            let rel_path = path.strip_prefix(&self.project_path)
199                .map(|p| p.to_string_lossy().to_string())
200                .unwrap_or_else(|_| file_name.clone());
201
202            let is_dir = path.is_dir();
203
204            if filter.is_empty() || rel_path.to_lowercase().contains(filter) || file_name.to_lowercase().contains(filter) {
205                let display = if is_dir {
206                    format!("{}/", rel_path)
207                } else {
208                    rel_path.clone()
209                };
210                results.push(Suggestion {
211                    display: display.clone(),
212                    value: display,
213                    is_dir,
214                });
215            }
216
217            if is_dir && !skip_dirs.contains(&file_name.as_str()) {
218                self.walk_dir(&path, filter, results, depth + 1, max_depth);
219            }
220        }
221    }
222
223    /// Search for slash commands matching filter
224    fn search_commands(&self, filter: &str) -> Vec<Suggestion> {
225        let filter_lower = filter.to_lowercase();
226
227        SLASH_COMMANDS.iter()
228            .filter(|cmd| {
229                cmd.name.to_lowercase().starts_with(&filter_lower) ||
230                cmd.alias.map(|a| a.to_lowercase().starts_with(&filter_lower)).unwrap_or(false)
231            })
232            .take(8)
233            .map(|cmd| Suggestion {
234                display: format!("/{:<12} {}", cmd.name, cmd.description),
235                value: format!("/{}", cmd.name),
236                is_dir: false,
237            })
238            .collect()
239    }
240
241    /// Close suggestions dropdown
242    fn close_suggestions(&mut self) {
243        self.showing_suggestions = false;
244        self.suggestions.clear();
245        self.selected = -1;
246        self.completion_start = None;
247    }
248
249    /// Move selection up
250    fn select_up(&mut self) {
251        if self.showing_suggestions && !self.suggestions.is_empty() {
252            if self.selected > 0 {
253                self.selected -= 1;
254            }
255        }
256    }
257
258    /// Move selection down
259    fn select_down(&mut self) {
260        if self.showing_suggestions && !self.suggestions.is_empty() {
261            if self.selected < self.suggestions.len() as i32 - 1 {
262                self.selected += 1;
263            }
264        }
265    }
266
267    /// Accept the current selection
268    fn accept_selection(&mut self) -> bool {
269        if self.showing_suggestions && self.selected >= 0 {
270            if let Some(suggestion) = self.suggestions.get(self.selected as usize) {
271                if let Some(start) = self.completion_start {
272                    // Replace @filter with @value
273                    let before = self.text.chars().take(start).collect::<String>();
274                    let after = self.text.chars().skip(self.cursor).collect::<String>();
275
276                    // For files, use @path format; for commands, use /command
277                    let replacement = if suggestion.value.starts_with('/') {
278                        format!("{} ", suggestion.value)
279                    } else {
280                        format!("@{} ", suggestion.value)
281                    };
282
283                    self.text = format!("{}{}{}", before, replacement, after);
284                    self.cursor = before.len() + replacement.len();
285                }
286                self.close_suggestions();
287                return true;
288            }
289        }
290        false
291    }
292
293    /// Move cursor left
294    fn cursor_left(&mut self) {
295        if self.cursor > 0 {
296            self.cursor -= 1;
297        }
298    }
299
300    /// Move cursor right
301    fn cursor_right(&mut self) {
302        if self.cursor < self.text.chars().count() {
303            self.cursor += 1;
304        }
305    }
306
307    /// Move cursor to start
308    fn cursor_home(&mut self) {
309        self.cursor = 0;
310    }
311
312    /// Move cursor to end
313    fn cursor_end(&mut self) {
314        self.cursor = self.text.chars().count();
315    }
316}
317
318/// Render the input UI
319fn render(state: &InputState, prompt: &str, stdout: &mut io::Stdout) -> io::Result<usize> {
320    // Clear current line and render input
321    execute!(stdout, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
322
323    // Print prompt and input text
324    print!("{}{}{} {}", ansi::SUCCESS, prompt, ansi::RESET, state.text);
325
326    // Render suggestions below if active
327    let mut lines_rendered = 0;
328    if state.showing_suggestions && !state.suggestions.is_empty() {
329        println!(); // Move to next line
330        lines_rendered += 1;
331
332        for (i, suggestion) in state.suggestions.iter().enumerate() {
333            let is_selected = i as i32 == state.selected;
334            let prefix = if is_selected { "▸" } else { " " };
335
336            if is_selected {
337                if suggestion.is_dir {
338                    println!("\r  {}{} {}{}", ansi::CYAN, prefix, suggestion.display, ansi::RESET);
339                } else {
340                    println!("\r  {}{} {}{}", ansi::WHITE, prefix, suggestion.display, ansi::RESET);
341                }
342            } else {
343                println!("\r  {}{} {}{}", ansi::DIM, prefix, suggestion.display, ansi::RESET);
344            }
345            lines_rendered += 1;
346        }
347
348        // Print hint
349        println!("\r  {}[↑↓ navigate, Enter select, Esc cancel]{}", ansi::DIM, ansi::RESET);
350        lines_rendered += 1;
351
352        // Move cursor back up to input line
353        execute!(stdout, MoveUp(lines_rendered as u16))?;
354    }
355
356    // Position cursor correctly within input
357    let prompt_visual_len = prompt.len() + 1; // +1 for space
358    let cursor_col = prompt_visual_len + state.text.chars().take(state.cursor).count();
359    execute!(stdout, MoveToColumn(cursor_col as u16))?;
360
361    stdout.flush()?;
362    Ok(lines_rendered)
363}
364
365/// Clear rendered suggestion lines
366fn clear_suggestions(num_lines: usize, stdout: &mut io::Stdout) -> io::Result<()> {
367    if num_lines > 0 {
368        // Save position, clear lines below, restore
369        for _ in 0..num_lines {
370            execute!(stdout,
371                cursor::MoveDown(1),
372                Clear(ClearType::CurrentLine)
373            )?;
374        }
375        execute!(stdout, MoveUp(num_lines as u16))?;
376    }
377    Ok(())
378}
379
380/// Read user input with Claude Code-style @ file picker
381pub fn read_input_with_file_picker(prompt: &str, project_path: &PathBuf) -> InputResult {
382    let mut stdout = io::stdout();
383    let mut state = InputState::new(project_path.clone());
384
385    // Enable raw mode
386    if terminal::enable_raw_mode().is_err() {
387        return read_simple_input(prompt);
388    }
389
390    // Initial render
391    print!("{}{}{} ", ansi::SUCCESS, prompt, ansi::RESET);
392    let _ = stdout.flush();
393
394    let result = loop {
395        match event::read() {
396            Ok(Event::Key(key_event)) => {
397                // Clear previous suggestions before processing
398                if state.rendered_lines > 0 {
399                    let _ = clear_suggestions(state.rendered_lines, &mut stdout);
400                }
401
402                match key_event.code {
403                    KeyCode::Enter => {
404                        if state.showing_suggestions && state.selected >= 0 {
405                            // Accept selection, don't submit
406                            state.accept_selection();
407                        } else if !state.text.trim().is_empty() {
408                            // Submit
409                            print!("\r\n");
410                            let _ = stdout.flush();
411                            break InputResult::Submit(state.text.clone());
412                        }
413                    }
414                    KeyCode::Tab => {
415                        // Tab also accepts selection
416                        if state.showing_suggestions && state.selected >= 0 {
417                            state.accept_selection();
418                        }
419                    }
420                    KeyCode::Esc => {
421                        if state.showing_suggestions {
422                            state.close_suggestions();
423                        } else {
424                            print!("\r\n");
425                            let _ = stdout.flush();
426                            break InputResult::Cancel;
427                        }
428                    }
429                    KeyCode::Char('c') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
430                        if !state.text.is_empty() {
431                            // Clear input
432                            state.text.clear();
433                            state.cursor = 0;
434                            state.close_suggestions();
435                        } else {
436                            print!("\r\n");
437                            let _ = stdout.flush();
438                            break InputResult::Cancel;
439                        }
440                    }
441                    KeyCode::Char('d') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
442                        print!("\r\n");
443                        let _ = stdout.flush();
444                        break InputResult::Exit;
445                    }
446                    KeyCode::Up => {
447                        if state.showing_suggestions {
448                            state.select_up();
449                        }
450                    }
451                    KeyCode::Down => {
452                        if state.showing_suggestions {
453                            state.select_down();
454                        }
455                    }
456                    KeyCode::Left => {
457                        state.cursor_left();
458                        // Close suggestions if cursor moves before @
459                        if let Some(start) = state.completion_start {
460                            if state.cursor <= start {
461                                state.close_suggestions();
462                            }
463                        }
464                    }
465                    KeyCode::Right => {
466                        state.cursor_right();
467                    }
468                    KeyCode::Home | KeyCode::Char('a') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
469                        state.cursor_home();
470                        state.close_suggestions();
471                    }
472                    KeyCode::End | KeyCode::Char('e') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
473                        state.cursor_end();
474                    }
475                    KeyCode::Backspace => {
476                        state.backspace();
477                    }
478                    KeyCode::Char(c) => {
479                        state.insert_char(c);
480                    }
481                    _ => {}
482                }
483
484                // Re-render
485                state.rendered_lines = render(&state, prompt, &mut stdout).unwrap_or(0);
486            }
487            Ok(Event::Resize(_, _)) => {
488                // Redraw on resize
489                state.rendered_lines = render(&state, prompt, &mut stdout).unwrap_or(0);
490            }
491            Err(_) => {
492                break InputResult::Cancel;
493            }
494            _ => {}
495        }
496    };
497
498    // Disable raw mode
499    let _ = terminal::disable_raw_mode();
500
501    // Clean up any remaining rendered lines
502    if state.rendered_lines > 0 {
503        let _ = clear_suggestions(state.rendered_lines, &mut stdout);
504    }
505
506    result
507}
508
509/// Simple fallback input without raw mode
510fn read_simple_input(prompt: &str) -> InputResult {
511    print!("{}{}{} ", ansi::SUCCESS, prompt, ansi::RESET);
512    let _ = io::stdout().flush();
513
514    let mut input = String::new();
515    match io::stdin().read_line(&mut input) {
516        Ok(_) => {
517            let trimmed = input.trim();
518            if trimmed.eq_ignore_ascii_case("exit") || trimmed == "/exit" || trimmed == "/quit" {
519                InputResult::Exit
520            } else {
521                InputResult::Submit(trimmed.to_string())
522            }
523        }
524        Err(_) => InputResult::Cancel,
525    }
526}