1use crate::agent::commands::SLASH_COMMANDS;
11use crate::agent::ui::colors::ansi;
12use crossterm::{
13 cursor::{self, MoveUp},
14 event::{self, DisableBracketedPaste, EnableBracketedPaste, 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 prev_wrapped_lines: usize,
59}
60
61impl InputState {
62 fn new(project_path: PathBuf) -> Self {
63 Self {
64 text: String::new(),
65 cursor: 0,
66 suggestions: Vec::new(),
67 selected: -1,
68 showing_suggestions: false,
69 completion_start: None,
70 project_path,
71 rendered_lines: 0,
72 prev_wrapped_lines: 1,
73 }
74 }
75
76 fn insert_char(&mut self, c: char) {
78 if c == '\r' {
80 return;
81 }
82
83 let byte_pos = self.char_to_byte_pos(self.cursor);
85 self.text.insert(byte_pos, c);
86 self.cursor += 1;
87
88 if c == '@' {
90 let valid_trigger = self.cursor == 1 ||
91 self.text.chars().nth(self.cursor - 2).map(|c| c.is_whitespace()).unwrap_or(false);
92 if valid_trigger {
93 self.completion_start = Some(self.cursor - 1);
94 self.refresh_suggestions();
95 }
96 } else if c == '/' && self.cursor == 1 {
97 self.completion_start = Some(0);
99 self.refresh_suggestions();
100 } else if c.is_whitespace() {
101 self.close_suggestions();
103 } else if self.completion_start.is_some() {
104 self.refresh_suggestions();
106 }
107 }
108
109 fn backspace(&mut self) {
111 if self.cursor > 0 {
112 let byte_pos = self.char_to_byte_pos(self.cursor - 1);
113 let next_byte_pos = self.char_to_byte_pos(self.cursor);
114 self.text.replace_range(byte_pos..next_byte_pos, "");
115 self.cursor -= 1;
116
117 if let Some(start) = self.completion_start {
119 if self.cursor <= start {
120 self.close_suggestions();
121 } else {
122 self.refresh_suggestions();
123 }
124 }
125 }
126 }
127
128 fn delete_word_left(&mut self) {
130 if self.cursor == 0 {
131 return;
132 }
133
134 let chars: Vec<char> = self.text.chars().collect();
135 let mut new_cursor = self.cursor;
136
137 while new_cursor > 0 && chars[new_cursor - 1].is_whitespace() {
139 new_cursor -= 1;
140 }
141
142 while new_cursor > 0 && !chars[new_cursor - 1].is_whitespace() {
144 new_cursor -= 1;
145 }
146
147 let start_byte = self.char_to_byte_pos(new_cursor);
149 let end_byte = self.char_to_byte_pos(self.cursor);
150 self.text.replace_range(start_byte..end_byte, "");
151 self.cursor = new_cursor;
152
153 if let Some(start) = self.completion_start {
155 if self.cursor <= start {
156 self.close_suggestions();
157 } else {
158 self.refresh_suggestions();
159 }
160 }
161 }
162
163 fn clear_all(&mut self) {
165 self.text.clear();
166 self.cursor = 0;
167 self.close_suggestions();
168 }
169
170 fn delete_to_line_start(&mut self) {
172 if self.cursor == 0 {
173 return;
174 }
175
176 let chars: Vec<char> = self.text.chars().collect();
177
178 let mut line_start = self.cursor;
180 while line_start > 0 && chars[line_start - 1] != '\n' {
181 line_start -= 1;
182 }
183
184 if line_start == self.cursor && self.cursor > 0 {
186 line_start -= 1;
187 }
188
189 let start_byte = self.char_to_byte_pos(line_start);
191 let end_byte = self.char_to_byte_pos(self.cursor);
192 self.text.replace_range(start_byte..end_byte, "");
193 self.cursor = line_start;
194
195 self.close_suggestions();
196 }
197
198 fn char_to_byte_pos(&self, char_pos: usize) -> usize {
200 self.text.char_indices()
201 .nth(char_pos)
202 .map(|(i, _)| i)
203 .unwrap_or(self.text.len())
204 }
205
206 fn get_filter(&self) -> Option<String> {
208 self.completion_start.map(|start| {
209 let filter_start = start + 1; if filter_start <= self.cursor {
211 self.text.chars().skip(filter_start).take(self.cursor - filter_start).collect()
212 } else {
213 String::new()
214 }
215 })
216 }
217
218 fn refresh_suggestions(&mut self) {
220 let filter = self.get_filter().unwrap_or_default();
221 let trigger = self.completion_start
222 .and_then(|pos| self.text.chars().nth(pos));
223
224 self.suggestions = match trigger {
225 Some('@') => self.search_files(&filter),
226 Some('/') => self.search_commands(&filter),
227 _ => Vec::new(),
228 };
229
230 self.showing_suggestions = !self.suggestions.is_empty();
231 self.selected = if self.showing_suggestions { 0 } else { -1 };
232 }
233
234 fn search_files(&self, filter: &str) -> Vec<Suggestion> {
236 let mut results = Vec::new();
237 let filter_lower = filter.to_lowercase();
238
239 self.walk_dir(&self.project_path.clone(), &filter_lower, &mut results, 0, 4);
240
241 results.sort_by(|a, b| {
243 match (a.is_dir, b.is_dir) {
244 (true, false) => std::cmp::Ordering::Less,
245 (false, true) => std::cmp::Ordering::Greater,
246 _ => a.value.len().cmp(&b.value.len()),
247 }
248 });
249
250 results.truncate(8);
251 results
252 }
253
254 fn walk_dir(&self, dir: &PathBuf, filter: &str, results: &mut Vec<Suggestion>, depth: usize, max_depth: usize) {
256 if depth > max_depth || results.len() >= 20 {
257 return;
258 }
259
260 let skip_dirs = ["node_modules", ".git", "target", "__pycache__", ".venv", "venv", "dist", "build", ".next"];
261
262 let entries = match std::fs::read_dir(dir) {
263 Ok(e) => e,
264 Err(_) => return,
265 };
266
267 for entry in entries.flatten() {
268 let path = entry.path();
269 let file_name = entry.file_name().to_string_lossy().to_string();
270
271 if file_name.starts_with('.') && !file_name.starts_with(".env") && file_name != ".gitignore" {
273 continue;
274 }
275
276 let rel_path = path.strip_prefix(&self.project_path)
277 .map(|p| p.to_string_lossy().to_string())
278 .unwrap_or_else(|_| file_name.clone());
279
280 let is_dir = path.is_dir();
281
282 if filter.is_empty() || rel_path.to_lowercase().contains(filter) || file_name.to_lowercase().contains(filter) {
283 let display = if is_dir {
284 format!("{}/", rel_path)
285 } else {
286 rel_path.clone()
287 };
288 results.push(Suggestion {
289 display: display.clone(),
290 value: display,
291 is_dir,
292 });
293 }
294
295 if is_dir && !skip_dirs.contains(&file_name.as_str()) {
296 self.walk_dir(&path, filter, results, depth + 1, max_depth);
297 }
298 }
299 }
300
301 fn search_commands(&self, filter: &str) -> Vec<Suggestion> {
303 let filter_lower = filter.to_lowercase();
304
305 SLASH_COMMANDS.iter()
306 .filter(|cmd| {
307 cmd.name.to_lowercase().starts_with(&filter_lower) ||
308 cmd.alias.map(|a| a.to_lowercase().starts_with(&filter_lower)).unwrap_or(false)
309 })
310 .take(8)
311 .map(|cmd| Suggestion {
312 display: format!("/{:<12} {}", cmd.name, cmd.description),
313 value: format!("/{}", cmd.name),
314 is_dir: false,
315 })
316 .collect()
317 }
318
319 fn close_suggestions(&mut self) {
321 self.showing_suggestions = false;
322 self.suggestions.clear();
323 self.selected = -1;
324 self.completion_start = None;
325 }
326
327 fn select_up(&mut self) {
329 if self.showing_suggestions && !self.suggestions.is_empty() {
330 if self.selected > 0 {
331 self.selected -= 1;
332 }
333 }
334 }
335
336 fn select_down(&mut self) {
338 if self.showing_suggestions && !self.suggestions.is_empty() {
339 if self.selected < self.suggestions.len() as i32 - 1 {
340 self.selected += 1;
341 }
342 }
343 }
344
345 fn accept_selection(&mut self) -> bool {
347 if self.showing_suggestions && self.selected >= 0 {
348 if let Some(suggestion) = self.suggestions.get(self.selected as usize) {
349 if let Some(start) = self.completion_start {
350 let before = self.text.chars().take(start).collect::<String>();
352 let after = self.text.chars().skip(self.cursor).collect::<String>();
353
354 let replacement = if suggestion.value.starts_with('/') {
356 format!("{} ", suggestion.value)
357 } else {
358 format!("@{} ", suggestion.value)
359 };
360
361 self.text = format!("{}{}{}", before, replacement, after);
362 self.cursor = before.len() + replacement.len();
363 }
364 self.close_suggestions();
365 return true;
366 }
367 }
368 false
369 }
370
371 fn cursor_left(&mut self) {
373 if self.cursor > 0 {
374 self.cursor -= 1;
375 }
376 }
377
378 fn cursor_right(&mut self) {
380 if self.cursor < self.text.chars().count() {
381 self.cursor += 1;
382 }
383 }
384
385 fn cursor_home(&mut self) {
387 self.cursor = 0;
388 }
389
390 fn cursor_end(&mut self) {
392 self.cursor = self.text.chars().count();
393 }
394
395 fn cursor_up(&mut self) {
397 let chars: Vec<char> = self.text.chars().collect();
398 if self.cursor == 0 {
399 return;
400 }
401
402 let mut current_line_start = self.cursor;
404 while current_line_start > 0 && chars[current_line_start - 1] != '\n' {
405 current_line_start -= 1;
406 }
407
408 if current_line_start == 0 {
410 return;
411 }
412
413 let col = self.cursor - current_line_start;
415
416 let prev_line_end = current_line_start - 1; let mut prev_line_start = prev_line_end;
419 while prev_line_start > 0 && chars[prev_line_start - 1] != '\n' {
420 prev_line_start -= 1;
421 }
422
423 let prev_line_len = prev_line_end - prev_line_start;
425
426 self.cursor = prev_line_start + col.min(prev_line_len);
428 }
429
430 fn cursor_down(&mut self) {
432 let chars: Vec<char> = self.text.chars().collect();
433 let text_len = chars.len();
434
435 let mut current_line_start = self.cursor;
437 while current_line_start > 0 && chars[current_line_start - 1] != '\n' {
438 current_line_start -= 1;
439 }
440
441 let col = self.cursor - current_line_start;
443
444 let mut current_line_end = self.cursor;
446 while current_line_end < text_len && chars[current_line_end] != '\n' {
447 current_line_end += 1;
448 }
449
450 if current_line_end >= text_len {
452 return;
453 }
454
455 let next_line_start = current_line_end + 1;
457
458 let mut next_line_end = next_line_start;
460 while next_line_end < text_len && chars[next_line_end] != '\n' {
461 next_line_end += 1;
462 }
463
464 let next_line_len = next_line_end - next_line_start;
466
467 self.cursor = next_line_start + col.min(next_line_len);
469 }
470}
471
472fn render(state: &mut InputState, prompt: &str, stdout: &mut io::Stdout) -> io::Result<usize> {
474 let (term_width, _) = terminal::size().unwrap_or((80, 24));
476 let term_width = term_width as usize;
477
478 let prompt_len = prompt.len() + 1; if state.prev_wrapped_lines > 1 {
483 execute!(stdout, cursor::MoveUp((state.prev_wrapped_lines - 1) as u16))?;
484 }
485 execute!(stdout, cursor::MoveToColumn(0))?;
486
487 execute!(stdout, Clear(ClearType::FromCursorDown))?;
489
490 let display_text = state.text.replace('\n', "\r\n");
493 print!("{}{}{} {}", ansi::SUCCESS, prompt, ansi::RESET, display_text);
494 stdout.flush()?;
495
496 let mut total_lines = 1;
498 let mut current_line_len = prompt_len;
499
500 for c in state.text.chars() {
501 if c == '\n' {
502 total_lines += 1;
503 current_line_len = 0;
504 } else {
505 current_line_len += 1;
506 if term_width > 0 && current_line_len > term_width {
507 total_lines += 1;
508 current_line_len = 1;
509 }
510 }
511 }
512 state.prev_wrapped_lines = total_lines;
513
514 let mut lines_rendered = 0;
516 if state.showing_suggestions && !state.suggestions.is_empty() {
517 print!("\r\n");
519 lines_rendered += 1;
520
521 for (i, suggestion) in state.suggestions.iter().enumerate() {
522 let is_selected = i as i32 == state.selected;
523 let prefix = if is_selected { "▸" } else { " " };
524
525 if is_selected {
526 if suggestion.is_dir {
527 print!(" {}{} {}{}\r\n", ansi::CYAN, prefix, suggestion.display, ansi::RESET);
528 } else {
529 print!(" {}{} {}{}\r\n", ansi::WHITE, prefix, suggestion.display, ansi::RESET);
530 }
531 } else {
532 print!(" {}{} {}{}\r\n", ansi::DIM, prefix, suggestion.display, ansi::RESET);
533 }
534 lines_rendered += 1;
535 }
536
537 print!(" {}[↑↓ navigate, Enter select, Esc cancel]{}\r\n", ansi::DIM, ansi::RESET);
539 lines_rendered += 1;
540 }
541
542 let mut cursor_line = 0;
545 let mut cursor_col = prompt_len;
546
547 for (i, c) in state.text.chars().enumerate() {
548 if i >= state.cursor {
549 break;
550 }
551 if c == '\n' {
552 cursor_line += 1;
553 cursor_col = 0;
554 } else {
555 cursor_col += 1;
556 if term_width > 0 && cursor_col >= term_width {
557 cursor_line += 1;
558 cursor_col = 0;
559 }
560 }
561 }
562
563 let lines_after_cursor = total_lines.saturating_sub(cursor_line + 1) + lines_rendered;
565 if lines_after_cursor > 0 {
566 execute!(stdout, cursor::MoveUp(lines_after_cursor as u16))?;
567 }
568 execute!(stdout, cursor::MoveToColumn(cursor_col as u16))?;
569
570 stdout.flush()?;
571 Ok(lines_rendered)
572}
573
574fn clear_suggestions(num_lines: usize, stdout: &mut io::Stdout) -> io::Result<()> {
576 if num_lines > 0 {
577 for _ in 0..num_lines {
579 execute!(stdout,
580 cursor::MoveDown(1),
581 Clear(ClearType::CurrentLine)
582 )?;
583 }
584 execute!(stdout, MoveUp(num_lines as u16))?;
585 }
586 Ok(())
587}
588
589pub fn read_input_with_file_picker(prompt: &str, project_path: &PathBuf) -> InputResult {
591 let mut stdout = io::stdout();
592
593 if terminal::enable_raw_mode().is_err() {
595 return read_simple_input(prompt);
596 }
597
598 let _ = execute!(stdout, EnableBracketedPaste);
600
601 print!("{}{}{} ", ansi::SUCCESS, prompt, ansi::RESET);
603 let _ = stdout.flush();
604
605 let mut state = InputState::new(project_path.clone());
607
608 let result = loop {
609 match event::read() {
610 Ok(Event::Paste(pasted_text)) => {
612 let normalized = pasted_text.replace("\r\n", "\n").replace('\r', "\n");
614 for c in normalized.chars() {
615 state.insert_char(c);
616 }
617 state.rendered_lines = render(&mut state, prompt, &mut stdout).unwrap_or(0);
619 }
620 Ok(Event::Key(key_event)) => {
621 match key_event.code {
622 KeyCode::Enter => {
623 if key_event.modifiers.contains(KeyModifiers::SHIFT) ||
625 key_event.modifiers.contains(KeyModifiers::ALT) {
626 state.insert_char('\n');
627 } else if state.showing_suggestions && state.selected >= 0 {
628 state.accept_selection();
630 } else if !state.text.trim().is_empty() {
631 print!("\r\n");
633 let _ = stdout.flush();
634 break InputResult::Submit(state.text.clone());
635 }
636 }
637 KeyCode::Tab => {
638 if state.showing_suggestions && state.selected >= 0 {
640 state.accept_selection();
641 }
642 }
643 KeyCode::Esc => {
644 if state.showing_suggestions {
645 state.close_suggestions();
646 } else {
647 print!("\r\n");
648 let _ = stdout.flush();
649 break InputResult::Cancel;
650 }
651 }
652 KeyCode::Char('c') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
653 if !state.text.is_empty() {
654 state.text.clear();
656 state.cursor = 0;
657 state.close_suggestions();
658 } else {
659 print!("\r\n");
660 let _ = stdout.flush();
661 break InputResult::Cancel;
662 }
663 }
664 KeyCode::Char('d') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
665 print!("\r\n");
666 let _ = stdout.flush();
667 break InputResult::Exit;
668 }
669 KeyCode::Up => {
670 if state.showing_suggestions {
671 state.select_up();
672 } else {
673 state.cursor_up();
674 }
675 }
676 KeyCode::Down => {
677 if state.showing_suggestions {
678 state.select_down();
679 } else {
680 state.cursor_down();
681 }
682 }
683 KeyCode::Left => {
684 state.cursor_left();
685 if let Some(start) = state.completion_start {
687 if state.cursor <= start {
688 state.close_suggestions();
689 }
690 }
691 }
692 KeyCode::Right => {
693 state.cursor_right();
694 }
695 KeyCode::Home | KeyCode::Char('a') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
696 state.cursor_home();
697 state.close_suggestions();
698 }
699 KeyCode::End | KeyCode::Char('e') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
700 state.cursor_end();
701 }
702 KeyCode::Char('u') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
704 state.clear_all();
705 }
706 KeyCode::Char('k') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
708 state.delete_to_line_start();
709 }
710 KeyCode::Backspace if key_event.modifiers.contains(KeyModifiers::CONTROL)
712 && key_event.modifiers.contains(KeyModifiers::SHIFT) => {
713 state.delete_to_line_start();
714 }
715 KeyCode::Backspace if key_event.modifiers.contains(KeyModifiers::SUPER) => {
717 state.delete_to_line_start();
718 }
719 KeyCode::Char('w') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
721 state.delete_word_left();
722 }
723 KeyCode::Backspace if key_event.modifiers.contains(KeyModifiers::ALT) => {
724 state.delete_word_left();
725 }
726 KeyCode::Backspace if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
727 state.delete_word_left();
728 }
729 KeyCode::Char('j') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
731 state.insert_char('\n');
732 }
733 KeyCode::Backspace => {
734 state.backspace();
735 }
736 KeyCode::Char('\n') => {
737 state.insert_char('\n');
739 }
740 KeyCode::Char(c) => {
741 state.insert_char(c);
742 }
743 _ => {}
744 }
745
746 let should_render = !event::poll(std::time::Duration::from_millis(0)).unwrap_or(false);
749 if should_render {
750 state.rendered_lines = render(&mut state, prompt, &mut stdout).unwrap_or(0);
751 }
752 }
753 Ok(Event::Resize(_, _)) => {
754 state.rendered_lines = render(&mut state, prompt, &mut stdout).unwrap_or(0);
756 }
757 Err(_) => {
758 break InputResult::Cancel;
759 }
760 _ => {}
761 }
762 };
763
764 let _ = execute!(stdout, DisableBracketedPaste);
766
767 let _ = terminal::disable_raw_mode();
769
770 if state.rendered_lines > 0 {
772 let _ = clear_suggestions(state.rendered_lines, &mut stdout);
773 }
774
775 result
776}
777
778fn read_simple_input(prompt: &str) -> InputResult {
780 print!("{}{}{} ", ansi::SUCCESS, prompt, ansi::RESET);
781 let _ = io::stdout().flush();
782
783 let mut input = String::new();
784 match io::stdin().read_line(&mut input) {
785 Ok(_) => {
786 let trimmed = input.trim();
787 if trimmed.eq_ignore_ascii_case("exit") || trimmed == "/exit" || trimmed == "/quit" {
788 InputResult::Exit
789 } else {
790 InputResult::Submit(trimmed.to_string())
791 }
792 }
793 Err(_) => InputResult::Cancel,
794 }
795}