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.text.chars().nth(self.cursor - 2).map(|c| c.is_whitespace()).unwrap_or(false);
97 if valid_trigger {
98 self.completion_start = Some(self.cursor - 1);
99 self.refresh_suggestions();
100 }
101 } else if c == '/' && self.cursor == 1 {
102 self.completion_start = Some(0);
104 self.refresh_suggestions();
105 } else if c.is_whitespace() {
106 self.close_suggestions();
108 } else if self.completion_start.is_some() {
109 self.refresh_suggestions();
111 }
112 }
113
114 fn backspace(&mut self) {
116 if self.cursor > 0 {
117 let byte_pos = self.char_to_byte_pos(self.cursor - 1);
118 let next_byte_pos = self.char_to_byte_pos(self.cursor);
119 self.text.replace_range(byte_pos..next_byte_pos, "");
120 self.cursor -= 1;
121
122 if let Some(start) = self.completion_start {
124 if self.cursor <= start {
125 self.close_suggestions();
126 } else {
127 self.refresh_suggestions();
128 }
129 }
130 }
131 }
132
133 fn delete_word_left(&mut self) {
135 if self.cursor == 0 {
136 return;
137 }
138
139 let chars: Vec<char> = self.text.chars().collect();
140 let mut new_cursor = self.cursor;
141
142 while new_cursor > 0 && chars[new_cursor - 1].is_whitespace() {
144 new_cursor -= 1;
145 }
146
147 while new_cursor > 0 && !chars[new_cursor - 1].is_whitespace() {
149 new_cursor -= 1;
150 }
151
152 let start_byte = self.char_to_byte_pos(new_cursor);
154 let end_byte = self.char_to_byte_pos(self.cursor);
155 self.text.replace_range(start_byte..end_byte, "");
156 self.cursor = new_cursor;
157
158 if let Some(start) = self.completion_start {
160 if self.cursor <= start {
161 self.close_suggestions();
162 } else {
163 self.refresh_suggestions();
164 }
165 }
166 }
167
168 fn clear_all(&mut self) {
170 self.text.clear();
171 self.cursor = 0;
172 self.close_suggestions();
173 }
174
175 fn delete_to_line_start(&mut self) {
177 if self.cursor == 0 {
178 return;
179 }
180
181 let chars: Vec<char> = self.text.chars().collect();
182
183 let mut line_start = self.cursor;
185 while line_start > 0 && chars[line_start - 1] != '\n' {
186 line_start -= 1;
187 }
188
189 if line_start == self.cursor && self.cursor > 0 {
191 line_start -= 1;
192 }
193
194 let start_byte = self.char_to_byte_pos(line_start);
196 let end_byte = self.char_to_byte_pos(self.cursor);
197 self.text.replace_range(start_byte..end_byte, "");
198 self.cursor = line_start;
199
200 self.close_suggestions();
201 }
202
203 fn char_to_byte_pos(&self, char_pos: usize) -> usize {
205 self.text.char_indices()
206 .nth(char_pos)
207 .map(|(i, _)| i)
208 .unwrap_or(self.text.len())
209 }
210
211 fn get_filter(&self) -> Option<String> {
213 self.completion_start.map(|start| {
214 let filter_start = start + 1; if filter_start <= self.cursor {
216 self.text.chars().skip(filter_start).take(self.cursor - filter_start).collect()
217 } else {
218 String::new()
219 }
220 })
221 }
222
223 fn refresh_suggestions(&mut self) {
225 let filter = self.get_filter().unwrap_or_default();
226 let trigger = self.completion_start
227 .and_then(|pos| self.text.chars().nth(pos));
228
229 self.suggestions = match trigger {
230 Some('@') => self.search_files(&filter),
231 Some('/') => self.search_commands(&filter),
232 _ => Vec::new(),
233 };
234
235 self.showing_suggestions = !self.suggestions.is_empty();
236 self.selected = if self.showing_suggestions { 0 } else { -1 };
237 }
238
239 fn search_files(&self, filter: &str) -> Vec<Suggestion> {
241 let mut results = Vec::new();
242 let filter_lower = filter.to_lowercase();
243
244 self.walk_dir(&self.project_path.clone(), &filter_lower, &mut results, 0, 4);
245
246 results.sort_by(|a, b| {
248 match (a.is_dir, b.is_dir) {
249 (true, false) => std::cmp::Ordering::Less,
250 (false, true) => std::cmp::Ordering::Greater,
251 _ => a.value.len().cmp(&b.value.len()),
252 }
253 });
254
255 results.truncate(8);
256 results
257 }
258
259 fn walk_dir(&self, dir: &PathBuf, filter: &str, results: &mut Vec<Suggestion>, depth: usize, max_depth: usize) {
261 if depth > max_depth || results.len() >= 20 {
262 return;
263 }
264
265 let skip_dirs = ["node_modules", ".git", "target", "__pycache__", ".venv", "venv", "dist", "build", ".next"];
266
267 let entries = match std::fs::read_dir(dir) {
268 Ok(e) => e,
269 Err(_) => return,
270 };
271
272 for entry in entries.flatten() {
273 let path = entry.path();
274 let file_name = entry.file_name().to_string_lossy().to_string();
275
276 if file_name.starts_with('.') && !file_name.starts_with(".env") && file_name != ".gitignore" {
278 continue;
279 }
280
281 let rel_path = path.strip_prefix(&self.project_path)
282 .map(|p| p.to_string_lossy().to_string())
283 .unwrap_or_else(|_| file_name.clone());
284
285 let is_dir = path.is_dir();
286
287 if filter.is_empty() || rel_path.to_lowercase().contains(filter) || file_name.to_lowercase().contains(filter) {
288 let display = if is_dir {
289 format!("{}/", rel_path)
290 } else {
291 rel_path.clone()
292 };
293 results.push(Suggestion {
294 display: display.clone(),
295 value: display,
296 is_dir,
297 });
298 }
299
300 if is_dir && !skip_dirs.contains(&file_name.as_str()) {
301 self.walk_dir(&path, filter, results, depth + 1, max_depth);
302 }
303 }
304 }
305
306 fn search_commands(&self, filter: &str) -> Vec<Suggestion> {
308 let filter_lower = filter.to_lowercase();
309
310 SLASH_COMMANDS.iter()
311 .filter(|cmd| {
312 cmd.name.to_lowercase().starts_with(&filter_lower) ||
313 cmd.alias.map(|a| a.to_lowercase().starts_with(&filter_lower)).unwrap_or(false)
314 })
315 .take(8)
316 .map(|cmd| Suggestion {
317 display: format!("/{:<12} {}", cmd.name, cmd.description),
318 value: format!("/{}", cmd.name),
319 is_dir: false,
320 })
321 .collect()
322 }
323
324 fn close_suggestions(&mut self) {
326 self.showing_suggestions = false;
327 self.suggestions.clear();
328 self.selected = -1;
329 self.completion_start = None;
330 }
331
332 fn select_up(&mut self) {
334 if self.showing_suggestions && !self.suggestions.is_empty() {
335 if self.selected > 0 {
336 self.selected -= 1;
337 }
338 }
339 }
340
341 fn select_down(&mut self) {
343 if self.showing_suggestions && !self.suggestions.is_empty() {
344 if self.selected < self.suggestions.len() as i32 - 1 {
345 self.selected += 1;
346 }
347 }
348 }
349
350 fn accept_selection(&mut self) -> bool {
352 if self.showing_suggestions && self.selected >= 0 {
353 if let Some(suggestion) = self.suggestions.get(self.selected as usize) {
354 if let Some(start) = self.completion_start {
355 let before = self.text.chars().take(start).collect::<String>();
357 let after = self.text.chars().skip(self.cursor).collect::<String>();
358
359 let replacement = if suggestion.value.starts_with('/') {
361 format!("{} ", suggestion.value)
362 } else {
363 format!("@{} ", suggestion.value)
364 };
365
366 self.text = format!("{}{}{}", before, replacement, after);
367 self.cursor = before.len() + replacement.len();
368 }
369 self.close_suggestions();
370 return true;
371 }
372 }
373 false
374 }
375
376 fn cursor_left(&mut self) {
378 if self.cursor > 0 {
379 self.cursor -= 1;
380 }
381 }
382
383 fn cursor_right(&mut self) {
385 if self.cursor < self.text.chars().count() {
386 self.cursor += 1;
387 }
388 }
389
390 fn cursor_home(&mut self) {
392 self.cursor = 0;
393 }
394
395 fn cursor_end(&mut self) {
397 self.cursor = self.text.chars().count();
398 }
399
400 fn cursor_up(&mut self) {
402 let chars: Vec<char> = self.text.chars().collect();
403 if self.cursor == 0 {
404 return;
405 }
406
407 let mut current_line_start = self.cursor;
409 while current_line_start > 0 && chars[current_line_start - 1] != '\n' {
410 current_line_start -= 1;
411 }
412
413 if current_line_start == 0 {
415 return;
416 }
417
418 let col = self.cursor - current_line_start;
420
421 let prev_line_end = current_line_start - 1; let mut prev_line_start = prev_line_end;
424 while prev_line_start > 0 && chars[prev_line_start - 1] != '\n' {
425 prev_line_start -= 1;
426 }
427
428 let prev_line_len = prev_line_end - prev_line_start;
430
431 self.cursor = prev_line_start + col.min(prev_line_len);
433 }
434
435 fn cursor_down(&mut self) {
437 let chars: Vec<char> = self.text.chars().collect();
438 let text_len = chars.len();
439
440 let mut current_line_start = self.cursor;
442 while current_line_start > 0 && chars[current_line_start - 1] != '\n' {
443 current_line_start -= 1;
444 }
445
446 let col = self.cursor - current_line_start;
448
449 let mut current_line_end = self.cursor;
451 while current_line_end < text_len && chars[current_line_end] != '\n' {
452 current_line_end += 1;
453 }
454
455 if current_line_end >= text_len {
457 return;
458 }
459
460 let next_line_start = current_line_end + 1;
462
463 let mut next_line_end = next_line_start;
465 while next_line_end < text_len && chars[next_line_end] != '\n' {
466 next_line_end += 1;
467 }
468
469 let next_line_len = next_line_end - next_line_start;
471
472 self.cursor = next_line_start + col.min(next_line_len);
474 }
475}
476
477fn render(state: &mut InputState, prompt: &str, stdout: &mut io::Stdout) -> io::Result<usize> {
479 let (term_width, _) = terminal::size().unwrap_or((80, 24));
481 let term_width = term_width as usize;
482
483 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 {
489 execute!(stdout, cursor::MoveUp((state.prev_wrapped_lines - 1) as u16))?;
490 }
491 execute!(stdout, cursor::MoveToColumn(0))?;
492
493 execute!(stdout, Clear(ClearType::FromCursorDown))?;
495
496 let display_text = state.text.replace('\n', "\r\n");
499 if state.plan_mode {
500 print!("{}★{} {}{}{} {}", ansi::ORANGE, ansi::RESET, ansi::SUCCESS, prompt, ansi::RESET, display_text);
501 } else {
502 print!("{}{}{} {}", ansi::SUCCESS, prompt, ansi::RESET, display_text);
503 }
504 stdout.flush()?;
505
506 let mut total_lines = 1;
508 let mut current_line_len = prompt_len;
509
510 for c in state.text.chars() {
511 if c == '\n' {
512 total_lines += 1;
513 current_line_len = 0;
514 } else {
515 current_line_len += 1;
516 if term_width > 0 && current_line_len > term_width {
517 total_lines += 1;
518 current_line_len = 1;
519 }
520 }
521 }
522 state.prev_wrapped_lines = total_lines;
523
524 let mut lines_rendered = 0;
526 if state.showing_suggestions && !state.suggestions.is_empty() {
527 print!("\r\n");
529 lines_rendered += 1;
530
531 for (i, suggestion) in state.suggestions.iter().enumerate() {
532 let is_selected = i as i32 == state.selected;
533 let prefix = if is_selected { "▸" } else { " " };
534
535 if is_selected {
536 if suggestion.is_dir {
537 print!(" {}{} {}{}\r\n", ansi::CYAN, prefix, suggestion.display, ansi::RESET);
538 } else {
539 print!(" {}{} {}{}\r\n", ansi::WHITE, prefix, suggestion.display, ansi::RESET);
540 }
541 } else {
542 print!(" {}{} {}{}\r\n", ansi::DIM, prefix, suggestion.display, ansi::RESET);
543 }
544 lines_rendered += 1;
545 }
546
547 print!(" {}[↑↓ navigate, Enter select, Esc cancel]{}\r\n", ansi::DIM, ansi::RESET);
549 lines_rendered += 1;
550 }
551
552 let mut cursor_line = 0;
555 let mut cursor_col = prompt_len;
556
557 for (i, c) in state.text.chars().enumerate() {
558 if i >= state.cursor {
559 break;
560 }
561 if c == '\n' {
562 cursor_line += 1;
563 cursor_col = 0;
564 } else {
565 cursor_col += 1;
566 if term_width > 0 && cursor_col >= term_width {
567 cursor_line += 1;
568 cursor_col = 0;
569 }
570 }
571 }
572
573 let lines_after_cursor = total_lines.saturating_sub(cursor_line + 1) + lines_rendered;
575 if lines_after_cursor > 0 {
576 execute!(stdout, cursor::MoveUp(lines_after_cursor as u16))?;
577 }
578 execute!(stdout, cursor::MoveToColumn(cursor_col as u16))?;
579
580 stdout.flush()?;
581 Ok(lines_rendered)
582}
583
584fn clear_suggestions(num_lines: usize, stdout: &mut io::Stdout) -> io::Result<()> {
586 if num_lines > 0 {
587 for _ in 0..num_lines {
589 execute!(stdout,
590 cursor::MoveDown(1),
591 Clear(ClearType::CurrentLine)
592 )?;
593 }
594 execute!(stdout, MoveUp(num_lines as u16))?;
595 }
596 Ok(())
597}
598
599pub fn read_input_with_file_picker(prompt: &str, project_path: &PathBuf, plan_mode: bool) -> InputResult {
602 let mut stdout = io::stdout();
603
604 if terminal::enable_raw_mode().is_err() {
606 return read_simple_input(prompt);
607 }
608
609 let _ = execute!(stdout, EnableBracketedPaste);
611
612 if plan_mode {
614 print!("{}★{} {}{}{} ", ansi::ORANGE, ansi::RESET, ansi::SUCCESS, prompt, ansi::RESET);
615 } else {
616 print!("{}{}{} ", ansi::SUCCESS, prompt, ansi::RESET);
617 }
618 let _ = stdout.flush();
619
620 let mut state = InputState::new(project_path.clone(), plan_mode);
622
623 let result = loop {
624 match event::read() {
625 Ok(Event::Paste(pasted_text)) => {
627 let normalized = pasted_text.replace("\r\n", "\n").replace('\r', "\n");
629 for c in normalized.chars() {
630 state.insert_char(c);
631 }
632 state.rendered_lines = render(&mut state, prompt, &mut stdout).unwrap_or(0);
634 }
635 Ok(Event::Key(key_event)) => {
636 match key_event.code {
637 KeyCode::Enter => {
638 if key_event.modifiers.contains(KeyModifiers::SHIFT) ||
640 key_event.modifiers.contains(KeyModifiers::ALT) {
641 state.insert_char('\n');
642 } else if state.showing_suggestions && state.selected >= 0 {
643 state.accept_selection();
645 } else if !state.text.trim().is_empty() {
646 print!("\r\n");
648 let _ = stdout.flush();
649 break InputResult::Submit(state.text.clone());
650 }
651 }
652 KeyCode::Tab => {
653 if state.showing_suggestions && state.selected >= 0 {
655 state.accept_selection();
656 }
657 }
658 KeyCode::BackTab => {
659 print!("\r\n");
661 let _ = stdout.flush();
662 break InputResult::TogglePlanMode;
663 }
664 KeyCode::Esc => {
665 if state.showing_suggestions {
666 state.close_suggestions();
667 } else {
668 print!("\r\n");
669 let _ = stdout.flush();
670 break InputResult::Cancel;
671 }
672 }
673 KeyCode::Char('c') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
674 print!("\r\n");
676 let _ = stdout.flush();
677 break InputResult::Cancel;
678 }
679 KeyCode::Char('d') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
680 print!("\r\n");
681 let _ = stdout.flush();
682 break InputResult::Exit;
683 }
684 KeyCode::Up => {
685 if state.showing_suggestions {
686 state.select_up();
687 } else {
688 state.cursor_up();
689 }
690 }
691 KeyCode::Down => {
692 if state.showing_suggestions {
693 state.select_down();
694 } else {
695 state.cursor_down();
696 }
697 }
698 KeyCode::Left => {
699 state.cursor_left();
700 if let Some(start) = state.completion_start {
702 if state.cursor <= start {
703 state.close_suggestions();
704 }
705 }
706 }
707 KeyCode::Right => {
708 state.cursor_right();
709 }
710 KeyCode::Home | KeyCode::Char('a') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
711 state.cursor_home();
712 state.close_suggestions();
713 }
714 KeyCode::End | KeyCode::Char('e') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
715 state.cursor_end();
716 }
717 KeyCode::Char('u') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
719 state.clear_all();
720 }
721 KeyCode::Char('k') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
723 state.delete_to_line_start();
724 }
725 KeyCode::Backspace if key_event.modifiers.contains(KeyModifiers::CONTROL)
727 && key_event.modifiers.contains(KeyModifiers::SHIFT) => {
728 state.delete_to_line_start();
729 }
730 KeyCode::Backspace if key_event.modifiers.contains(KeyModifiers::SUPER) => {
732 state.delete_to_line_start();
733 }
734 KeyCode::Char('w') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
736 state.delete_word_left();
737 }
738 KeyCode::Backspace if key_event.modifiers.contains(KeyModifiers::ALT) => {
739 state.delete_word_left();
740 }
741 KeyCode::Backspace if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
742 state.delete_word_left();
743 }
744 KeyCode::Char('j') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
746 state.insert_char('\n');
747 }
748 KeyCode::Backspace => {
749 state.backspace();
750 }
751 KeyCode::Char('\n') => {
752 state.insert_char('\n');
754 }
755 KeyCode::Char(c) => {
756 state.insert_char(c);
757 }
758 _ => {}
759 }
760
761 let should_render = !event::poll(std::time::Duration::from_millis(0)).unwrap_or(false);
764 if should_render {
765 state.rendered_lines = render(&mut state, prompt, &mut stdout).unwrap_or(0);
766 }
767 }
768 Ok(Event::Resize(_, _)) => {
769 state.rendered_lines = render(&mut state, prompt, &mut stdout).unwrap_or(0);
771 }
772 Err(_) => {
773 break InputResult::Cancel;
774 }
775 _ => {}
776 }
777 };
778
779 let _ = execute!(stdout, DisableBracketedPaste);
781
782 let _ = terminal::disable_raw_mode();
784
785 if state.rendered_lines > 0 {
787 let _ = clear_suggestions(state.rendered_lines, &mut stdout);
788 }
789
790 result
791}
792
793fn read_simple_input(prompt: &str) -> InputResult {
795 print!("{}{}{} ", ansi::SUCCESS, prompt, ansi::RESET);
796 let _ = io::stdout().flush();
797
798 let mut input = String::new();
799 match io::stdin().read_line(&mut input) {
800 Ok(_) => {
801 let trimmed = input.trim();
802 if trimmed.eq_ignore_ascii_case("exit") || trimmed == "/exit" || trimmed == "/quit" {
803 InputResult::Exit
804 } else {
805 InputResult::Submit(trimmed.to_string())
806 }
807 }
808 Err(_) => InputResult::Cancel,
809 }
810}