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 TogglePlanMode,
31}
32
33#[derive(Clone)]
35struct Suggestion {
36 display: String,
37 value: String,
38 is_dir: bool,
39}
40
41struct InputState {
43 text: String,
45 cursor: usize,
47 suggestions: Vec<Suggestion>,
49 selected: i32,
51 showing_suggestions: bool,
53 completion_start: Option<usize>,
55 project_path: PathBuf,
57 rendered_lines: usize,
59 prev_wrapped_lines: usize,
61 plan_mode: bool,
63}
64
65impl InputState {
66 fn new(project_path: PathBuf, plan_mode: bool) -> Self {
67 Self {
68 text: String::new(),
69 cursor: 0,
70 suggestions: Vec::new(),
71 selected: -1,
72 showing_suggestions: false,
73 completion_start: None,
74 project_path,
75 rendered_lines: 0,
76 prev_wrapped_lines: 1,
77 plan_mode,
78 }
79 }
80
81 fn insert_char(&mut self, c: char) {
83 if c == '\r' {
85 return;
86 }
87
88 let byte_pos = self.char_to_byte_pos(self.cursor);
90 self.text.insert(byte_pos, c);
91 self.cursor += 1;
92
93 if c == '@' {
95 let valid_trigger = self.cursor == 1
96 || self
97 .text
98 .chars()
99 .nth(self.cursor - 2)
100 .map(|c| c.is_whitespace())
101 .unwrap_or(false);
102 if valid_trigger {
103 self.completion_start = Some(self.cursor - 1);
104 self.refresh_suggestions();
105 }
106 } else if c == '/' && self.cursor == 1 {
107 self.completion_start = Some(0);
109 self.refresh_suggestions();
110 } else if c.is_whitespace() {
111 self.close_suggestions();
113 } else if self.completion_start.is_some() {
114 self.refresh_suggestions();
116 }
117 }
118
119 fn backspace(&mut self) {
121 if self.cursor > 0 {
122 let byte_pos = self.char_to_byte_pos(self.cursor - 1);
123 let next_byte_pos = self.char_to_byte_pos(self.cursor);
124 self.text.replace_range(byte_pos..next_byte_pos, "");
125 self.cursor -= 1;
126
127 if let Some(start) = self.completion_start {
129 if self.cursor <= start {
130 self.close_suggestions();
131 } else {
132 self.refresh_suggestions();
133 }
134 }
135 }
136 }
137
138 fn delete_word_left(&mut self) {
140 if self.cursor == 0 {
141 return;
142 }
143
144 let chars: Vec<char> = self.text.chars().collect();
145 let mut new_cursor = self.cursor;
146
147 while new_cursor > 0 && chars[new_cursor - 1].is_whitespace() {
149 new_cursor -= 1;
150 }
151
152 while new_cursor > 0 && !chars[new_cursor - 1].is_whitespace() {
154 new_cursor -= 1;
155 }
156
157 let start_byte = self.char_to_byte_pos(new_cursor);
159 let end_byte = self.char_to_byte_pos(self.cursor);
160 self.text.replace_range(start_byte..end_byte, "");
161 self.cursor = new_cursor;
162
163 if let Some(start) = self.completion_start {
165 if self.cursor <= start {
166 self.close_suggestions();
167 } else {
168 self.refresh_suggestions();
169 }
170 }
171 }
172
173 fn clear_all(&mut self) {
175 self.text.clear();
176 self.cursor = 0;
177 self.close_suggestions();
178 }
179
180 fn delete_to_line_start(&mut self) {
182 if self.cursor == 0 {
183 return;
184 }
185
186 let chars: Vec<char> = self.text.chars().collect();
187
188 let mut line_start = self.cursor;
190 while line_start > 0 && chars[line_start - 1] != '\n' {
191 line_start -= 1;
192 }
193
194 if line_start == self.cursor && self.cursor > 0 {
196 line_start -= 1;
197 }
198
199 let start_byte = self.char_to_byte_pos(line_start);
201 let end_byte = self.char_to_byte_pos(self.cursor);
202 self.text.replace_range(start_byte..end_byte, "");
203 self.cursor = line_start;
204
205 self.close_suggestions();
206 }
207
208 fn char_to_byte_pos(&self, char_pos: usize) -> usize {
210 self.text
211 .char_indices()
212 .nth(char_pos)
213 .map(|(i, _)| i)
214 .unwrap_or(self.text.len())
215 }
216
217 fn get_filter(&self) -> Option<String> {
219 self.completion_start.map(|start| {
220 let filter_start = start + 1; if filter_start <= self.cursor {
222 self.text
223 .chars()
224 .skip(filter_start)
225 .take(self.cursor - filter_start)
226 .collect()
227 } else {
228 String::new()
229 }
230 })
231 }
232
233 fn refresh_suggestions(&mut self) {
235 let filter = self.get_filter().unwrap_or_default();
236 let trigger = self
237 .completion_start
238 .and_then(|pos| self.text.chars().nth(pos));
239
240 self.suggestions = match trigger {
241 Some('@') => self.search_files(&filter),
242 Some('/') => self.search_commands(&filter),
243 _ => Vec::new(),
244 };
245
246 self.showing_suggestions = !self.suggestions.is_empty();
247 self.selected = if self.showing_suggestions { 0 } else { -1 };
248 }
249
250 fn search_files(&self, filter: &str) -> Vec<Suggestion> {
252 let mut results = Vec::new();
253 let filter_lower = filter.to_lowercase();
254
255 self.walk_dir(
256 &self.project_path.clone(),
257 &filter_lower,
258 &mut results,
259 0,
260 4,
261 );
262
263 results.sort_by(|a, b| match (a.is_dir, b.is_dir) {
265 (true, false) => std::cmp::Ordering::Less,
266 (false, true) => std::cmp::Ordering::Greater,
267 _ => a.value.len().cmp(&b.value.len()),
268 });
269
270 results.truncate(8);
271 results
272 }
273
274 fn walk_dir(
276 &self,
277 dir: &PathBuf,
278 filter: &str,
279 results: &mut Vec<Suggestion>,
280 depth: usize,
281 max_depth: usize,
282 ) {
283 if depth > max_depth || results.len() >= 20 {
284 return;
285 }
286
287 let skip_dirs = [
288 "node_modules",
289 ".git",
290 "target",
291 "__pycache__",
292 ".venv",
293 "venv",
294 "dist",
295 "build",
296 ".next",
297 ];
298
299 let entries = match std::fs::read_dir(dir) {
300 Ok(e) => e,
301 Err(_) => return,
302 };
303
304 for entry in entries.flatten() {
305 let path = entry.path();
306 let file_name = entry.file_name().to_string_lossy().to_string();
307
308 if file_name.starts_with('.')
310 && !file_name.starts_with(".env")
311 && file_name != ".gitignore"
312 {
313 continue;
314 }
315
316 let rel_path = path
317 .strip_prefix(&self.project_path)
318 .map(|p| p.to_string_lossy().to_string())
319 .unwrap_or_else(|_| file_name.clone());
320
321 let is_dir = path.is_dir();
322
323 if filter.is_empty()
324 || rel_path.to_lowercase().contains(filter)
325 || file_name.to_lowercase().contains(filter)
326 {
327 let display = if is_dir {
328 format!("{}/", rel_path)
329 } else {
330 rel_path.clone()
331 };
332 results.push(Suggestion {
333 display: display.clone(),
334 value: display,
335 is_dir,
336 });
337 }
338
339 if is_dir && !skip_dirs.contains(&file_name.as_str()) {
340 self.walk_dir(&path, filter, results, depth + 1, max_depth);
341 }
342 }
343 }
344
345 fn search_commands(&self, filter: &str) -> Vec<Suggestion> {
347 let filter_lower = filter.to_lowercase();
348
349 SLASH_COMMANDS
350 .iter()
351 .filter(|cmd| {
352 cmd.name.to_lowercase().starts_with(&filter_lower)
353 || cmd
354 .alias
355 .map(|a| a.to_lowercase().starts_with(&filter_lower))
356 .unwrap_or(false)
357 })
358 .take(8)
359 .map(|cmd| Suggestion {
360 display: format!("/{:<12} {}", cmd.name, cmd.description),
361 value: format!("/{}", cmd.name),
362 is_dir: false,
363 })
364 .collect()
365 }
366
367 fn close_suggestions(&mut self) {
369 self.showing_suggestions = false;
370 self.suggestions.clear();
371 self.selected = -1;
372 self.completion_start = None;
373 }
374
375 fn select_up(&mut self) {
377 if self.showing_suggestions && !self.suggestions.is_empty() && self.selected > 0 {
378 self.selected -= 1;
379 }
380 }
381
382 fn select_down(&mut self) {
384 if self.showing_suggestions
385 && !self.suggestions.is_empty()
386 && self.selected < self.suggestions.len() as i32 - 1
387 {
388 self.selected += 1;
389 }
390 }
391
392 fn accept_selection(&mut self) -> bool {
394 if self.showing_suggestions
395 && self.selected >= 0
396 && let Some(suggestion) = self.suggestions.get(self.selected as usize)
397 {
398 if let Some(start) = self.completion_start {
399 let before = self.text.chars().take(start).collect::<String>();
401 let after = self.text.chars().skip(self.cursor).collect::<String>();
402
403 let replacement = if suggestion.value.starts_with('/') {
405 format!("{} ", suggestion.value)
406 } else {
407 format!("@{} ", suggestion.value)
408 };
409
410 self.text = format!("{}{}{}", before, replacement, after);
411 self.cursor = before.len() + replacement.len();
412 }
413 self.close_suggestions();
414 return true;
415 }
416 false
417 }
418
419 fn cursor_left(&mut self) {
421 if self.cursor > 0 {
422 self.cursor -= 1;
423 }
424 }
425
426 fn cursor_right(&mut self) {
428 if self.cursor < self.text.chars().count() {
429 self.cursor += 1;
430 }
431 }
432
433 fn cursor_home(&mut self) {
435 self.cursor = 0;
436 }
437
438 fn cursor_end(&mut self) {
440 self.cursor = self.text.chars().count();
441 }
442
443 fn cursor_up(&mut self) {
445 let chars: Vec<char> = self.text.chars().collect();
446 if self.cursor == 0 {
447 return;
448 }
449
450 let mut current_line_start = self.cursor;
452 while current_line_start > 0 && chars[current_line_start - 1] != '\n' {
453 current_line_start -= 1;
454 }
455
456 if current_line_start == 0 {
458 return;
459 }
460
461 let col = self.cursor - current_line_start;
463
464 let prev_line_end = current_line_start - 1; let mut prev_line_start = prev_line_end;
467 while prev_line_start > 0 && chars[prev_line_start - 1] != '\n' {
468 prev_line_start -= 1;
469 }
470
471 let prev_line_len = prev_line_end - prev_line_start;
473
474 self.cursor = prev_line_start + col.min(prev_line_len);
476 }
477
478 fn cursor_down(&mut self) {
480 let chars: Vec<char> = self.text.chars().collect();
481 let text_len = chars.len();
482
483 let mut current_line_start = self.cursor;
485 while current_line_start > 0 && chars[current_line_start - 1] != '\n' {
486 current_line_start -= 1;
487 }
488
489 let col = self.cursor - current_line_start;
491
492 let mut current_line_end = self.cursor;
494 while current_line_end < text_len && chars[current_line_end] != '\n' {
495 current_line_end += 1;
496 }
497
498 if current_line_end >= text_len {
500 return;
501 }
502
503 let next_line_start = current_line_end + 1;
505
506 let mut next_line_end = next_line_start;
508 while next_line_end < text_len && chars[next_line_end] != '\n' {
509 next_line_end += 1;
510 }
511
512 let next_line_len = next_line_end - next_line_start;
514
515 self.cursor = next_line_start + col.min(next_line_len);
517 }
518}
519
520fn render(state: &mut InputState, prompt: &str, stdout: &mut io::Stdout) -> io::Result<usize> {
522 let (term_width, _) = terminal::size().unwrap_or((80, 24));
524 let term_width = term_width as usize;
525
526 let mode_prefix_len = if state.plan_mode { 2 } else { 0 }; let prompt_len = prompt.len() + 1 + mode_prefix_len; if state.prev_wrapped_lines > 1 {
532 execute!(
533 stdout,
534 cursor::MoveUp((state.prev_wrapped_lines - 1) as u16)
535 )?;
536 }
537 execute!(stdout, cursor::MoveToColumn(0))?;
538
539 execute!(stdout, Clear(ClearType::FromCursorDown))?;
541
542 let display_text = state.text.replace('\n', "\r\n");
545 if state.plan_mode {
546 print!(
547 "{}★{} {}{}{} {}",
548 ansi::ORANGE,
549 ansi::RESET,
550 ansi::SUCCESS,
551 prompt,
552 ansi::RESET,
553 display_text
554 );
555 } else {
556 print!(
557 "{}{}{} {}",
558 ansi::SUCCESS,
559 prompt,
560 ansi::RESET,
561 display_text
562 );
563 }
564 stdout.flush()?;
565
566 let mut total_lines = 1;
568 let mut current_line_len = prompt_len;
569
570 for c in state.text.chars() {
571 if c == '\n' {
572 total_lines += 1;
573 current_line_len = 0;
574 } else {
575 current_line_len += 1;
576 if term_width > 0 && current_line_len > term_width {
577 total_lines += 1;
578 current_line_len = 1;
579 }
580 }
581 }
582 state.prev_wrapped_lines = total_lines;
583
584 let mut lines_rendered = 0;
586 if state.showing_suggestions && !state.suggestions.is_empty() {
587 print!("\r\n");
589 lines_rendered += 1;
590
591 for (i, suggestion) in state.suggestions.iter().enumerate() {
592 let is_selected = i as i32 == state.selected;
593 let prefix = if is_selected { "▸" } else { " " };
594
595 if is_selected {
596 if suggestion.is_dir {
597 print!(
598 " {}{} {}{}\r\n",
599 ansi::CYAN,
600 prefix,
601 suggestion.display,
602 ansi::RESET
603 );
604 } else {
605 print!(
606 " {}{} {}{}\r\n",
607 ansi::WHITE,
608 prefix,
609 suggestion.display,
610 ansi::RESET
611 );
612 }
613 } else {
614 print!(
615 " {}{} {}{}\r\n",
616 ansi::DIM,
617 prefix,
618 suggestion.display,
619 ansi::RESET
620 );
621 }
622 lines_rendered += 1;
623 }
624
625 print!(
627 " {}[↑↓ navigate, Enter select, Esc cancel]{}\r\n",
628 ansi::DIM,
629 ansi::RESET
630 );
631 lines_rendered += 1;
632 }
633
634 let mut cursor_line = 0;
637 let mut cursor_col = prompt_len;
638
639 for (i, c) in state.text.chars().enumerate() {
640 if i >= state.cursor {
641 break;
642 }
643 if c == '\n' {
644 cursor_line += 1;
645 cursor_col = 0;
646 } else {
647 cursor_col += 1;
648 if term_width > 0 && cursor_col >= term_width {
649 cursor_line += 1;
650 cursor_col = 0;
651 }
652 }
653 }
654
655 let lines_after_cursor = total_lines.saturating_sub(cursor_line + 1) + lines_rendered;
657 if lines_after_cursor > 0 {
658 execute!(stdout, cursor::MoveUp(lines_after_cursor as u16))?;
659 }
660 execute!(stdout, cursor::MoveToColumn(cursor_col as u16))?;
661
662 stdout.flush()?;
663 Ok(lines_rendered)
664}
665
666fn clear_suggestions(num_lines: usize, stdout: &mut io::Stdout) -> io::Result<()> {
668 if num_lines > 0 {
669 for _ in 0..num_lines {
671 execute!(stdout, cursor::MoveDown(1), Clear(ClearType::CurrentLine))?;
672 }
673 execute!(stdout, MoveUp(num_lines as u16))?;
674 }
675 Ok(())
676}
677
678pub fn read_input_with_file_picker(
681 prompt: &str,
682 project_path: &std::path::Path,
683 plan_mode: bool,
684) -> InputResult {
685 let mut stdout = io::stdout();
686
687 if terminal::enable_raw_mode().is_err() {
689 return read_simple_input(prompt);
690 }
691
692 let _ = execute!(stdout, EnableBracketedPaste);
694
695 if plan_mode {
697 print!(
698 "{}★{} {}{}{} ",
699 ansi::ORANGE,
700 ansi::RESET,
701 ansi::SUCCESS,
702 prompt,
703 ansi::RESET
704 );
705 } else {
706 print!("{}{}{} ", ansi::SUCCESS, prompt, ansi::RESET);
707 }
708 let _ = stdout.flush();
709
710 let mut state = InputState::new(project_path.to_path_buf(), plan_mode);
712
713 let result = loop {
714 match event::read() {
715 Ok(Event::Paste(pasted_text)) => {
717 let normalized = pasted_text.replace("\r\n", "\n").replace('\r', "\n");
719 for c in normalized.chars() {
720 state.insert_char(c);
721 }
722 state.rendered_lines = render(&mut state, prompt, &mut stdout).unwrap_or(0);
724 }
725 Ok(Event::Key(key_event)) => {
726 match key_event.code {
727 KeyCode::Enter => {
728 if key_event.modifiers.contains(KeyModifiers::SHIFT)
730 || key_event.modifiers.contains(KeyModifiers::ALT)
731 {
732 state.insert_char('\n');
733 } else if state.showing_suggestions && state.selected >= 0 {
734 state.accept_selection();
736 } else if !state.text.trim().is_empty() {
737 print!("\r\n");
739 let _ = stdout.flush();
740 break InputResult::Submit(state.text.clone());
741 }
742 }
743 KeyCode::Tab => {
744 if state.showing_suggestions && state.selected >= 0 {
746 state.accept_selection();
747 }
748 }
749 KeyCode::BackTab => {
750 print!("\r\n");
752 let _ = stdout.flush();
753 break InputResult::TogglePlanMode;
754 }
755 KeyCode::Esc => {
756 if state.showing_suggestions {
757 state.close_suggestions();
758 } else {
759 print!("\r\n");
760 let _ = stdout.flush();
761 break InputResult::Cancel;
762 }
763 }
764 KeyCode::Char('c') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
765 print!("\r\n");
767 let _ = stdout.flush();
768 break InputResult::Cancel;
769 }
770 KeyCode::Char('d') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
771 print!("\r\n");
772 let _ = stdout.flush();
773 break InputResult::Exit;
774 }
775 KeyCode::Up => {
776 if state.showing_suggestions {
777 state.select_up();
778 } else {
779 state.cursor_up();
780 }
781 }
782 KeyCode::Down => {
783 if state.showing_suggestions {
784 state.select_down();
785 } else {
786 state.cursor_down();
787 }
788 }
789 KeyCode::Left => {
790 state.cursor_left();
791 if let Some(start) = state.completion_start
793 && state.cursor <= start
794 {
795 state.close_suggestions();
796 }
797 }
798 KeyCode::Right => {
799 state.cursor_right();
800 }
801 KeyCode::Home | KeyCode::Char('a')
802 if key_event.modifiers.contains(KeyModifiers::CONTROL) =>
803 {
804 state.cursor_home();
805 state.close_suggestions();
806 }
807 KeyCode::End | KeyCode::Char('e')
808 if key_event.modifiers.contains(KeyModifiers::CONTROL) =>
809 {
810 state.cursor_end();
811 }
812 KeyCode::Char('u') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
814 state.clear_all();
815 }
816 KeyCode::Char('k') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
818 state.delete_to_line_start();
819 }
820 KeyCode::Backspace
822 if key_event.modifiers.contains(KeyModifiers::CONTROL)
823 && key_event.modifiers.contains(KeyModifiers::SHIFT) =>
824 {
825 state.delete_to_line_start();
826 }
827 KeyCode::Backspace if key_event.modifiers.contains(KeyModifiers::SUPER) => {
829 state.delete_to_line_start();
830 }
831 KeyCode::Char('w') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
833 state.delete_word_left();
834 }
835 KeyCode::Backspace if key_event.modifiers.contains(KeyModifiers::ALT) => {
836 state.delete_word_left();
837 }
838 KeyCode::Backspace if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
839 state.delete_word_left();
840 }
841 KeyCode::Char('j') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
843 state.insert_char('\n');
844 }
845 KeyCode::Backspace => {
846 state.backspace();
847 }
848 KeyCode::Char('\n') => {
849 state.insert_char('\n');
851 }
852 KeyCode::Char(c) => {
853 state.insert_char(c);
854 }
855 _ => {}
856 }
857
858 let should_render =
861 !event::poll(std::time::Duration::from_millis(0)).unwrap_or(false);
862 if should_render {
863 state.rendered_lines = render(&mut state, prompt, &mut stdout).unwrap_or(0);
864 }
865 }
866 Ok(Event::Resize(_, _)) => {
867 state.rendered_lines = render(&mut state, prompt, &mut stdout).unwrap_or(0);
869 }
870 Err(_) => {
871 break InputResult::Cancel;
872 }
873 _ => {}
874 }
875 };
876
877 let _ = execute!(stdout, DisableBracketedPaste);
879
880 let _ = terminal::disable_raw_mode();
882
883 if state.rendered_lines > 0 {
885 let _ = clear_suggestions(state.rendered_lines, &mut stdout);
886 }
887
888 result
889}
890
891fn read_simple_input(prompt: &str) -> InputResult {
893 print!("{}{}{} ", ansi::SUCCESS, prompt, ansi::RESET);
894 let _ = io::stdout().flush();
895
896 let mut input = String::new();
897 match io::stdin().read_line(&mut input) {
898 Ok(_) => {
899 let trimmed = input.trim();
900 if trimmed.eq_ignore_ascii_case("exit") || trimmed == "/exit" || trimmed == "/quit" {
901 InputResult::Exit
902 } else {
903 InputResult::Submit(trimmed.to_string())
904 }
905 }
906 Err(_) => InputResult::Cancel,
907 }
908}