1use 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
21pub enum InputResult {
23 Submit(String),
25 Cancel,
27 Exit,
29}
30
31#[derive(Clone)]
33struct Suggestion {
34 display: String,
35 value: String,
36 is_dir: bool,
37}
38
39struct InputState {
41 text: String,
43 cursor: usize,
45 suggestions: Vec<Suggestion>,
47 selected: i32,
49 showing_suggestions: bool,
51 completion_start: Option<usize>,
53 project_path: PathBuf,
55 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 fn insert_char(&mut self, c: char) {
75 let byte_pos = self.char_to_byte_pos(self.cursor);
77 self.text.insert(byte_pos, c);
78 self.cursor += 1;
79
80 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 self.completion_start = Some(0);
91 self.refresh_suggestions();
92 } else if c.is_whitespace() {
93 self.close_suggestions();
95 } else if self.completion_start.is_some() {
96 self.refresh_suggestions();
98 }
99 }
100
101 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 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 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 fn get_filter(&self) -> Option<String> {
130 self.completion_start.map(|start| {
131 let filter_start = start + 1; 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 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 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 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 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 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 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 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 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 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 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 let before = self.text.chars().take(start).collect::<String>();
274 let after = self.text.chars().skip(self.cursor).collect::<String>();
275
276 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 fn cursor_left(&mut self) {
295 if self.cursor > 0 {
296 self.cursor -= 1;
297 }
298 }
299
300 fn cursor_right(&mut self) {
302 if self.cursor < self.text.chars().count() {
303 self.cursor += 1;
304 }
305 }
306
307 fn cursor_home(&mut self) {
309 self.cursor = 0;
310 }
311
312 fn cursor_end(&mut self) {
314 self.cursor = self.text.chars().count();
315 }
316}
317
318fn render(state: &InputState, prompt: &str, stdout: &mut io::Stdout) -> io::Result<usize> {
320 execute!(stdout, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
322
323 print!("{}{}{} {}", ansi::SUCCESS, prompt, ansi::RESET, state.text);
325
326 let mut lines_rendered = 0;
328 if state.showing_suggestions && !state.suggestions.is_empty() {
329 println!(); 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 println!("\r {}[↑↓ navigate, Enter select, Esc cancel]{}", ansi::DIM, ansi::RESET);
350 lines_rendered += 1;
351
352 execute!(stdout, MoveUp(lines_rendered as u16))?;
354 }
355
356 let prompt_visual_len = prompt.len() + 1; 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
365fn clear_suggestions(num_lines: usize, stdout: &mut io::Stdout) -> io::Result<()> {
367 if num_lines > 0 {
368 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
380pub 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 if terminal::enable_raw_mode().is_err() {
387 return read_simple_input(prompt);
388 }
389
390 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 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 state.accept_selection();
407 } else if !state.text.trim().is_empty() {
408 print!("\r\n");
410 let _ = stdout.flush();
411 break InputResult::Submit(state.text.clone());
412 }
413 }
414 KeyCode::Tab => {
415 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 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 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 state.rendered_lines = render(&state, prompt, &mut stdout).unwrap_or(0);
486 }
487 Ok(Event::Resize(_, _)) => {
488 state.rendered_lines = render(&state, prompt, &mut stdout).unwrap_or(0);
490 }
491 Err(_) => {
492 break InputResult::Cancel;
493 }
494 _ => {}
495 }
496 };
497
498 let _ = terminal::disable_raw_mode();
500
501 if state.rendered_lines > 0 {
503 let _ = clear_suggestions(state.rendered_lines, &mut stdout);
504 }
505
506 result
507}
508
509fn 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}