ratatui_toolkit/widgets/ai_chat/state/
input.rs

1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2use ratatui::style::{Color, Modifier, Style};
3use std::fs;
4use std::path::Path;
5
6/// State for text input with multi-line support and special prefix parsing.
7#[derive(Debug, Clone)]
8pub struct InputState {
9    /// Current input text
10    text: String,
11    /// Cursor position in text
12    cursor: usize,
13    /// Lines of text (split by newlines)
14    lines: Vec<String>,
15    /// Current line being edited
16    current_line: usize,
17    /// Whether @ prefix is active (file attachment mode)
18    is_file_mode: bool,
19    /// File search query
20    file_query: String,
21    /// Available files for fuzzy search
22    available_files: Vec<String>,
23    /// Selected file index in search results
24    selected_file_index: usize,
25    /// Whether / prefix is active (command mode)
26    is_command_mode: bool,
27    /// Command being entered
28    command: String,
29}
30
31impl Default for InputState {
32    fn default() -> Self {
33        Self {
34            text: String::new(),
35            cursor: 0,
36            lines: vec![String::new()],
37            current_line: 0,
38            is_file_mode: false,
39            file_query: String::new(),
40            available_files: Vec::new(),
41            selected_file_index: 0,
42            is_command_mode: false,
43            command: String::new(),
44        }
45    }
46}
47
48impl InputState {
49    /// Create a new input state.
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// Get current input text.
55    pub fn text(&self) -> &str {
56        &self.text
57    }
58
59    /// Get cursor position.
60    pub fn cursor(&self) -> usize {
61        self.cursor
62    }
63
64    /// Check if in file attachment mode.
65    pub fn is_file_mode(&self) -> bool {
66        self.is_file_mode
67    }
68
69    /// Check if in command mode.
70    pub fn is_command_mode(&self) -> bool {
71        self.is_command_mode
72    }
73
74    /// Get current file search query.
75    pub fn file_query(&self) -> &str {
76        &self.file_query
77    }
78
79    /// Get filtered files matching query.
80    pub fn filtered_files(&self) -> Vec<String> {
81        let query_lower = self.file_query.to_lowercase();
82        self.available_files
83            .iter()
84            .filter(|f| f.to_lowercase().contains(&query_lower))
85            .cloned()
86            .collect()
87    }
88
89    /// Get selected file index.
90    pub fn selected_file_index(&self) -> usize {
91        self.selected_file_index
92    }
93
94    /// Get current command.
95    pub fn command(&self) -> &str {
96        &self.command
97    }
98
99    /// Set available files for fuzzy search.
100    pub fn set_available_files(&mut self, files: Vec<String>) {
101        self.available_files = files;
102    }
103
104    /// Load files from current working directory.
105    ///
106    /// Filters out common ignore patterns:
107    /// - .git
108    /// - node_modules
109    /// - target
110    /// - __pycache__
111    /// - .venv
112    /// - venv
113    pub fn load_files_from_cwd(&mut self) {
114        let cwd = std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf());
115        let ignore_patterns = [
116            ".git",
117            "node_modules",
118            "target",
119            "__pycache__",
120            ".venv",
121            "venv",
122            "dist",
123            "build",
124            ".DS_Store",
125        ];
126
127        self.available_files = fs::read_dir(&cwd)
128            .into_iter()
129            .flatten()
130            .filter_map(|entry| entry.ok())
131            .filter_map(|entry| entry.file_name().to_str().map(|s| s.to_string()))
132            .filter(|name| {
133                !ignore_patterns
134                    .iter()
135                    .any(|pattern| name.eq_ignore_ascii_case(pattern) || name.starts_with(pattern))
136            })
137            .collect();
138    }
139
140    /// Handle a key event.
141    ///
142    /// Returns:
143    /// - `Some(text)` if Enter was pressed (submit message or command)
144    /// - `Some(file)` if a file was selected
145    /// - `None` otherwise
146    pub fn handle_key(&mut self, key: KeyEvent) -> Option<String> {
147        match key.code {
148            KeyCode::Char('@') => {
149                if !self.is_file_mode && !self.is_command_mode {
150                    self.is_file_mode = true;
151                }
152                None
153            }
154            KeyCode::Char('/') => {
155                if !self.is_file_mode && !self.is_command_mode {
156                    self.is_command_mode = true;
157                }
158                None
159            }
160            KeyCode::Char(c) => {
161                let is_ctrl_j = key.modifiers.contains(KeyModifiers::CONTROL) && c == 'j';
162
163                if is_ctrl_j {
164                    self.insert_newline();
165                } else if self.is_file_mode {
166                    self.file_query.push(c);
167                    self.selected_file_index = 0;
168                } else if self.is_command_mode {
169                    self.command.push(c);
170                } else {
171                    self.insert_char(c);
172                }
173                None
174            }
175            KeyCode::Backspace => {
176                if self.is_file_mode {
177                    if !self.file_query.is_empty() {
178                        self.file_query.pop();
179                        if self.file_query.is_empty() {
180                            self.is_file_mode = false;
181                        }
182                    }
183                } else if self.is_command_mode {
184                    self.command.pop();
185                    if self.command.is_empty() {
186                        self.is_command_mode = false;
187                    }
188                } else {
189                    self.backspace();
190                }
191                None
192            }
193            KeyCode::Left | KeyCode::Char('h') => {
194                if !self.is_file_mode && !self.is_command_mode && self.cursor > 0 {
195                    self.cursor -= 1;
196                }
197                None
198            }
199            KeyCode::Right | KeyCode::Char('l') => {
200                if !self.is_file_mode && !self.is_command_mode && self.cursor < self.text.len() {
201                    self.cursor += 1;
202                }
203                None
204            }
205            KeyCode::Up | KeyCode::Char('k') => {
206                if self.is_file_mode {
207                    let filtered = self.filtered_files();
208                    if !filtered.is_empty() {
209                        self.selected_file_index = if self.selected_file_index == 0 {
210                            filtered.len() - 1
211                        } else {
212                            self.selected_file_index - 1
213                        };
214                    }
215                }
216                None
217            }
218            KeyCode::Down | KeyCode::Char('j') => {
219                if self.is_file_mode {
220                    let filtered = self.filtered_files();
221                    if !filtered.is_empty() {
222                        self.selected_file_index = (self.selected_file_index + 1) % filtered.len();
223                    }
224                }
225                None
226            }
227            KeyCode::Enter => {
228                if self.is_file_mode {
229                    let filtered = self.filtered_files();
230                    if let Some(file) = filtered.get(self.selected_file_index) {
231                        let file = file.clone();
232                        self.is_file_mode = false;
233                        self.file_query.clear();
234                        self.selected_file_index = 0;
235                        Some(format!("@{}", file))
236                    } else {
237                        None
238                    }
239                } else if self.is_command_mode {
240                    let command = self.command.clone();
241                    self.is_command_mode = false;
242                    self.command.clear();
243                    Some(format!("/{}", command))
244                } else {
245                    let text = self.text.clone();
246                    self.clear();
247                    Some(text)
248                }
249            }
250            KeyCode::Esc => {
251                if self.is_file_mode {
252                    self.is_file_mode = false;
253                    self.file_query.clear();
254                    self.selected_file_index = 0;
255                }
256                if self.is_command_mode {
257                    self.is_command_mode = false;
258                    self.command.clear();
259                }
260                None
261            }
262            _ => None,
263        }
264    }
265
266    /// Insert a character at cursor position.
267    fn insert_char(&mut self, c: char) {
268        self.text.insert(self.cursor, c);
269        self.cursor += 1;
270        self.update_lines();
271    }
272
273    /// Insert a newline.
274    fn insert_newline(&mut self) {
275        self.text.insert(self.cursor, '\n');
276        self.cursor += 1;
277        self.update_lines();
278    }
279
280    /// Delete character before cursor.
281    fn backspace(&mut self) {
282        if self.cursor > 0 {
283            self.text.remove(self.cursor - 1);
284            self.cursor -= 1;
285            self.update_lines();
286        }
287    }
288
289    /// Clear input.
290    pub fn clear(&mut self) {
291        self.text.clear();
292        self.cursor = 0;
293        self.lines = vec![String::new()];
294        self.current_line = 0;
295    }
296
297    /// Update lines based on text.
298    fn update_lines(&mut self) {
299        self.lines = self.text.split('\n').map(|s| s.to_string()).collect();
300        if self.lines.is_empty() {
301            self.lines.push(String::new());
302        }
303    }
304
305    /// Update cursor position from current line.
306    fn update_cursor_from_lines(&mut self) {
307        let mut pos = 0;
308        for (i, line) in self.lines.iter().enumerate() {
309            if i == self.current_line {
310                self.cursor = pos + line.len();
311                return;
312            }
313            pos += line.len() + 1;
314        }
315    }
316}