syncable_cli/agent/ui/
input.rs

1//! Custom input handler with Claude Code-style @ file picker
2//!
3//! Provides:
4//! - Real-time inline file suggestions when typing @
5//! - Arrow key navigation in dropdown
6//! - **Enter to SELECT suggestion** (not submit)
7//! - Enter to SUBMIT only when no suggestions are active
8//! - Support for multiple @ file references
9
10use 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
21/// Result of reading user input
22pub enum InputResult {
23    /// User submitted text (Enter with no picker open)
24    Submit(String),
25    /// User cancelled (Ctrl+C or Escape with no picker)
26    Cancel,
27    /// User wants to exit
28    Exit,
29    /// User toggled planning mode (Shift+Tab)
30    TogglePlanMode,
31}
32
33/// Suggestion item
34#[derive(Clone)]
35struct Suggestion {
36    display: String,
37    value: String,
38    is_dir: bool,
39}
40
41/// Input state
42struct InputState {
43    /// Current input text
44    text: String,
45    /// Cursor position in text (character index)
46    cursor: usize,
47    /// Current suggestions
48    suggestions: Vec<Suggestion>,
49    /// Selected suggestion index (-1 = none selected)
50    selected: i32,
51    /// Whether suggestions dropdown is visible
52    showing_suggestions: bool,
53    /// Start position of current completion (@ position)
54    completion_start: Option<usize>,
55    /// Project path for file searches
56    project_path: PathBuf,
57    /// Number of lines rendered for suggestions (for cleanup)
58    rendered_lines: usize,
59    /// Number of wrapped lines the input text occupied in last render
60    prev_wrapped_lines: usize,
61    /// The line index (0-based) where cursor was positioned after last render
62    prev_cursor_line: usize,
63    /// Whether in plan mode (shows ★ indicator)
64    plan_mode: bool,
65}
66
67impl InputState {
68    fn new(project_path: PathBuf, plan_mode: bool) -> Self {
69        Self {
70            text: String::new(),
71            cursor: 0,
72            suggestions: Vec::new(),
73            selected: -1,
74            showing_suggestions: false,
75            completion_start: None,
76            project_path,
77            rendered_lines: 0,
78            prev_wrapped_lines: 1,
79            prev_cursor_line: 0,
80            plan_mode,
81        }
82    }
83
84    /// Insert character at cursor
85    fn insert_char(&mut self, c: char) {
86        // Skip carriage returns, keep newlines for multi-line support
87        if c == '\r' {
88            return;
89        }
90
91        // Insert at cursor position
92        let byte_pos = self.char_to_byte_pos(self.cursor);
93        self.text.insert(byte_pos, c);
94        self.cursor += 1;
95
96        // Check if we should trigger completion
97        if c == '@' {
98            let valid_trigger = self.cursor == 1
99                || self
100                    .text
101                    .chars()
102                    .nth(self.cursor - 2)
103                    .map(|c| c.is_whitespace())
104                    .unwrap_or(false);
105            if valid_trigger {
106                self.completion_start = Some(self.cursor - 1);
107                self.refresh_suggestions();
108            }
109        } else if c == '/' && self.cursor == 1 {
110            // Slash command at start
111            self.completion_start = Some(0);
112            self.refresh_suggestions();
113        } else if c.is_whitespace() {
114            // Space closes completion
115            self.close_suggestions();
116        } else if self.completion_start.is_some() {
117            // Continue filtering
118            self.refresh_suggestions();
119        }
120    }
121
122    /// Delete character before cursor
123    fn backspace(&mut self) {
124        if self.cursor > 0 {
125            let byte_pos = self.char_to_byte_pos(self.cursor - 1);
126            let next_byte_pos = self.char_to_byte_pos(self.cursor);
127            self.text.replace_range(byte_pos..next_byte_pos, "");
128            self.cursor -= 1;
129
130            // Check if we deleted the @ trigger
131            if let Some(start) = self.completion_start {
132                if self.cursor <= start {
133                    self.close_suggestions();
134                } else {
135                    self.refresh_suggestions();
136                }
137            }
138        }
139    }
140
141    /// Delete word before cursor (Ctrl+Backspace / Cmd+Delete / Alt+Backspace)
142    fn delete_word_left(&mut self) {
143        if self.cursor == 0 {
144            return;
145        }
146
147        let chars: Vec<char> = self.text.chars().collect();
148        let mut new_cursor = self.cursor;
149
150        // Skip whitespace going backwards
151        while new_cursor > 0 && chars[new_cursor - 1].is_whitespace() {
152            new_cursor -= 1;
153        }
154
155        // Skip word characters going backwards
156        while new_cursor > 0 && !chars[new_cursor - 1].is_whitespace() {
157            new_cursor -= 1;
158        }
159
160        // Delete from new_cursor to current cursor
161        let start_byte = self.char_to_byte_pos(new_cursor);
162        let end_byte = self.char_to_byte_pos(self.cursor);
163        self.text.replace_range(start_byte..end_byte, "");
164        self.cursor = new_cursor;
165
166        // Update suggestions if active
167        if let Some(start) = self.completion_start {
168            if self.cursor <= start {
169                self.close_suggestions();
170            } else {
171                self.refresh_suggestions();
172            }
173        }
174    }
175
176    /// Clear entire input (Ctrl+U)
177    fn clear_all(&mut self) {
178        self.text.clear();
179        self.cursor = 0;
180        self.close_suggestions();
181    }
182
183    /// Delete from cursor to beginning of current line (Cmd+Backspace)
184    fn delete_to_line_start(&mut self) {
185        if self.cursor == 0 {
186            return;
187        }
188
189        let chars: Vec<char> = self.text.chars().collect();
190
191        // Find the previous newline or start of text
192        let mut line_start = self.cursor;
193        while line_start > 0 && chars[line_start - 1] != '\n' {
194            line_start -= 1;
195        }
196
197        // If cursor is at line start, delete the newline to join with previous line
198        if line_start == self.cursor && self.cursor > 0 {
199            line_start -= 1;
200        }
201
202        // Delete from line_start to cursor
203        let start_byte = self.char_to_byte_pos(line_start);
204        let end_byte = self.char_to_byte_pos(self.cursor);
205        self.text.replace_range(start_byte..end_byte, "");
206        self.cursor = line_start;
207
208        self.close_suggestions();
209    }
210
211    /// Convert character position to byte position
212    fn char_to_byte_pos(&self, char_pos: usize) -> usize {
213        self.text
214            .char_indices()
215            .nth(char_pos)
216            .map(|(i, _)| i)
217            .unwrap_or(self.text.len())
218    }
219
220    /// Get the current filter text (after @ or /)
221    fn get_filter(&self) -> Option<String> {
222        self.completion_start.map(|start| {
223            let filter_start = start + 1; // Skip the @ or /
224            if filter_start <= self.cursor {
225                self.text
226                    .chars()
227                    .skip(filter_start)
228                    .take(self.cursor - filter_start)
229                    .collect()
230            } else {
231                String::new()
232            }
233        })
234    }
235
236    /// Refresh suggestions based on current filter
237    fn refresh_suggestions(&mut self) {
238        let filter = self.get_filter().unwrap_or_default();
239        let trigger = self
240            .completion_start
241            .and_then(|pos| self.text.chars().nth(pos));
242
243        self.suggestions = match trigger {
244            Some('@') => self.search_files(&filter),
245            Some('/') => self.search_commands(&filter),
246            _ => Vec::new(),
247        };
248
249        self.showing_suggestions = !self.suggestions.is_empty();
250        self.selected = if self.showing_suggestions { 0 } else { -1 };
251    }
252
253    /// Search for files matching filter
254    fn search_files(&self, filter: &str) -> Vec<Suggestion> {
255        let mut results = Vec::new();
256        let filter_lower = filter.to_lowercase();
257
258        self.walk_dir(
259            &self.project_path.clone(),
260            &filter_lower,
261            &mut results,
262            0,
263            4,
264        );
265
266        // Sort: directories first, then by path length
267        results.sort_by(|a, b| match (a.is_dir, b.is_dir) {
268            (true, false) => std::cmp::Ordering::Less,
269            (false, true) => std::cmp::Ordering::Greater,
270            _ => a.value.len().cmp(&b.value.len()),
271        });
272
273        results.truncate(8);
274        results
275    }
276
277    /// Walk directory tree for matching files
278    fn walk_dir(
279        &self,
280        dir: &PathBuf,
281        filter: &str,
282        results: &mut Vec<Suggestion>,
283        depth: usize,
284        max_depth: usize,
285    ) {
286        if depth > max_depth || results.len() >= 20 {
287            return;
288        }
289
290        let skip_dirs = [
291            "node_modules",
292            ".git",
293            "target",
294            "__pycache__",
295            ".venv",
296            "venv",
297            "dist",
298            "build",
299            ".next",
300        ];
301
302        let entries = match std::fs::read_dir(dir) {
303            Ok(e) => e,
304            Err(_) => return,
305        };
306
307        for entry in entries.flatten() {
308            let path = entry.path();
309            let file_name = entry.file_name().to_string_lossy().to_string();
310
311            // Skip hidden files (except some)
312            if file_name.starts_with('.')
313                && !file_name.starts_with(".env")
314                && file_name != ".gitignore"
315            {
316                continue;
317            }
318
319            let rel_path = path
320                .strip_prefix(&self.project_path)
321                .map(|p| p.to_string_lossy().to_string())
322                .unwrap_or_else(|_| file_name.clone());
323
324            let is_dir = path.is_dir();
325
326            if filter.is_empty()
327                || rel_path.to_lowercase().contains(filter)
328                || file_name.to_lowercase().contains(filter)
329            {
330                let display = if is_dir {
331                    format!("{}/", rel_path)
332                } else {
333                    rel_path.clone()
334                };
335                results.push(Suggestion {
336                    display: display.clone(),
337                    value: display,
338                    is_dir,
339                });
340            }
341
342            if is_dir && !skip_dirs.contains(&file_name.as_str()) {
343                self.walk_dir(&path, filter, results, depth + 1, max_depth);
344            }
345        }
346    }
347
348    /// Search for slash commands matching filter
349    fn search_commands(&self, filter: &str) -> Vec<Suggestion> {
350        let filter_lower = filter.to_lowercase();
351
352        SLASH_COMMANDS
353            .iter()
354            .filter(|cmd| {
355                cmd.name.to_lowercase().starts_with(&filter_lower)
356                    || cmd
357                        .alias
358                        .map(|a| a.to_lowercase().starts_with(&filter_lower))
359                        .unwrap_or(false)
360            })
361            .take(8)
362            .map(|cmd| Suggestion {
363                display: format!("/{:<12} {}", cmd.name, cmd.description),
364                value: format!("/{}", cmd.name),
365                is_dir: false,
366            })
367            .collect()
368    }
369
370    /// Close suggestions dropdown
371    fn close_suggestions(&mut self) {
372        self.showing_suggestions = false;
373        self.suggestions.clear();
374        self.selected = -1;
375        self.completion_start = None;
376    }
377
378    /// Move selection up
379    fn select_up(&mut self) {
380        if self.showing_suggestions && !self.suggestions.is_empty() && self.selected > 0 {
381            self.selected -= 1;
382        }
383    }
384
385    /// Move selection down
386    fn select_down(&mut self) {
387        if self.showing_suggestions
388            && !self.suggestions.is_empty()
389            && self.selected < self.suggestions.len() as i32 - 1
390        {
391            self.selected += 1;
392        }
393    }
394
395    /// Accept the current selection
396    fn accept_selection(&mut self) -> bool {
397        if self.showing_suggestions
398            && self.selected >= 0
399            && let Some(suggestion) = self.suggestions.get(self.selected as usize)
400        {
401            if let Some(start) = self.completion_start {
402                // Replace @filter with @value
403                let before = self.text.chars().take(start).collect::<String>();
404                let after = self.text.chars().skip(self.cursor).collect::<String>();
405
406                // For files, use @path format; for commands, use /command
407                let replacement = if suggestion.value.starts_with('/') {
408                    format!("{} ", suggestion.value)
409                } else {
410                    format!("@{} ", suggestion.value)
411                };
412
413                self.text = format!("{}{}{}", before, replacement, after);
414                self.cursor = before.len() + replacement.len();
415            }
416            self.close_suggestions();
417            return true;
418        }
419        false
420    }
421
422    /// Move cursor left
423    fn cursor_left(&mut self) {
424        if self.cursor > 0 {
425            self.cursor -= 1;
426        }
427    }
428
429    /// Move cursor right
430    fn cursor_right(&mut self) {
431        if self.cursor < self.text.chars().count() {
432            self.cursor += 1;
433        }
434    }
435
436    /// Move cursor to start of previous word (Option+Left on Mac, Ctrl+Left elsewhere)
437    fn cursor_word_left(&mut self) {
438        if self.cursor == 0 {
439            return;
440        }
441
442        let chars: Vec<char> = self.text.chars().collect();
443        let mut pos = self.cursor;
444
445        // Skip whitespace going backwards
446        while pos > 0 && chars[pos - 1].is_whitespace() {
447            pos -= 1;
448        }
449
450        // Skip word characters going backwards
451        while pos > 0 && !chars[pos - 1].is_whitespace() {
452            pos -= 1;
453        }
454
455        self.cursor = pos;
456    }
457
458    /// Move cursor to start of next word (Option+Right on Mac, Ctrl+Right elsewhere)
459    fn cursor_word_right(&mut self) {
460        let chars: Vec<char> = self.text.chars().collect();
461        let text_len = chars.len();
462
463        if self.cursor >= text_len {
464            return;
465        }
466
467        let mut pos = self.cursor;
468
469        // Skip current word characters
470        while pos < text_len && !chars[pos].is_whitespace() {
471            pos += 1;
472        }
473
474        // Skip whitespace
475        while pos < text_len && chars[pos].is_whitespace() {
476            pos += 1;
477        }
478
479        self.cursor = pos;
480    }
481
482    /// Move cursor to start
483    fn cursor_home(&mut self) {
484        self.cursor = 0;
485    }
486
487    /// Move cursor to end
488    fn cursor_end(&mut self) {
489        self.cursor = self.text.chars().count();
490    }
491
492    /// Move cursor up one line
493    fn cursor_up(&mut self) {
494        let chars: Vec<char> = self.text.chars().collect();
495        if self.cursor == 0 {
496            return;
497        }
498
499        // Find the start of the current line
500        let mut current_line_start = self.cursor;
501        while current_line_start > 0 && chars[current_line_start - 1] != '\n' {
502            current_line_start -= 1;
503        }
504
505        // If we're on the first line, can't go up
506        if current_line_start == 0 {
507            return;
508        }
509
510        // Find the column position on current line
511        let col = self.cursor - current_line_start;
512
513        // Find the start of the previous line
514        let prev_line_end = current_line_start - 1; // Position of the \n
515        let mut prev_line_start = prev_line_end;
516        while prev_line_start > 0 && chars[prev_line_start - 1] != '\n' {
517            prev_line_start -= 1;
518        }
519
520        // Calculate the length of the previous line
521        let prev_line_len = prev_line_end - prev_line_start;
522
523        // Move cursor to same column on previous line (or end if line is shorter)
524        self.cursor = prev_line_start + col.min(prev_line_len);
525    }
526
527    /// Move cursor down one line
528    fn cursor_down(&mut self) {
529        let chars: Vec<char> = self.text.chars().collect();
530        let text_len = chars.len();
531
532        // Find the start of the current line
533        let mut current_line_start = self.cursor;
534        while current_line_start > 0 && chars[current_line_start - 1] != '\n' {
535            current_line_start -= 1;
536        }
537
538        // Find the column position on current line
539        let col = self.cursor - current_line_start;
540
541        // Find the end of the current line (the \n or end of text)
542        let mut current_line_end = self.cursor;
543        while current_line_end < text_len && chars[current_line_end] != '\n' {
544            current_line_end += 1;
545        }
546
547        // If we're on the last line, can't go down
548        if current_line_end >= text_len {
549            return;
550        }
551
552        // Find the start of the next line
553        let next_line_start = current_line_end + 1;
554
555        // Find the end of the next line
556        let mut next_line_end = next_line_start;
557        while next_line_end < text_len && chars[next_line_end] != '\n' {
558            next_line_end += 1;
559        }
560
561        // Calculate the length of the next line
562        let next_line_len = next_line_end - next_line_start;
563
564        // Move cursor to same column on next line (or end if line is shorter)
565        self.cursor = next_line_start + col.min(next_line_len);
566    }
567}
568
569/// Render the input UI with multi-line support
570fn render(state: &mut InputState, prompt: &str, stdout: &mut io::Stdout) -> io::Result<usize> {
571    // Get terminal width
572    let (term_width, _) = terminal::size().unwrap_or((80, 24));
573    let term_width = term_width as usize;
574
575    // Calculate prompt length (include ★ prefix if in plan mode)
576    let mode_prefix_len = if state.plan_mode { 2 } else { 0 }; // "★ " = 2 chars
577    let prompt_len = prompt.len() + 1 + mode_prefix_len; // +1 for space after prompt
578
579    // Move up from the cursor's current line position to the start of input
580    // We use prev_cursor_line (where we left the cursor last render) not prev_wrapped_lines
581    if state.prev_cursor_line > 0 {
582        execute!(stdout, cursor::MoveUp(state.prev_cursor_line as u16))?;
583    }
584    execute!(stdout, cursor::MoveToColumn(0))?;
585
586    // Clear from cursor to end of screen
587    execute!(stdout, Clear(ClearType::FromCursorDown))?;
588
589    // Print prompt and input text with mode indicator if in plan mode
590    // In raw mode, \n doesn't return to column 0, so we need \r\n
591    let display_text = state.text.replace('\n', "\r\n");
592    if state.plan_mode {
593        print!(
594            "{}★{} {}{}{} {}",
595            ansi::ORANGE,
596            ansi::RESET,
597            ansi::SUCCESS,
598            prompt,
599            ansi::RESET,
600            display_text
601        );
602    } else {
603        print!(
604            "{}{}{} {}",
605            ansi::SUCCESS,
606            prompt,
607            ansi::RESET,
608            display_text
609        );
610    }
611    stdout.flush()?;
612
613    // Calculate how many lines the text spans (counting newlines + wrapping)
614    let mut total_lines = 1;
615    let mut current_line_len = prompt_len;
616
617    for c in state.text.chars() {
618        if c == '\n' {
619            total_lines += 1;
620            current_line_len = 0;
621        } else {
622            current_line_len += 1;
623            if term_width > 0 && current_line_len > term_width {
624                total_lines += 1;
625                current_line_len = 1;
626            }
627        }
628    }
629    state.prev_wrapped_lines = total_lines;
630
631    // Render suggestions below if active
632    let mut lines_rendered = 0;
633    if state.showing_suggestions && !state.suggestions.is_empty() {
634        // Move to next line for suggestions (use \r\n in raw mode)
635        print!("\r\n");
636        lines_rendered += 1;
637
638        for (i, suggestion) in state.suggestions.iter().enumerate() {
639            let is_selected = i as i32 == state.selected;
640            let prefix = if is_selected { "▸" } else { " " };
641
642            if is_selected {
643                if suggestion.is_dir {
644                    // Use standard cyan which adapts to terminal theme
645                    print!(
646                        "  {}{}{} {}{}\r\n",
647                        ansi::BOLD,
648                        ansi::STD_CYAN,
649                        prefix,
650                        suggestion.display,
651                        ansi::RESET
652                    );
653                } else {
654                    // Use bold for selected items - works on light AND dark terminals
655                    print!(
656                        "  {}{} {}{}\r\n",
657                        ansi::BRIGHT,
658                        prefix,
659                        suggestion.display,
660                        ansi::RESET
661                    );
662                }
663            } else {
664                // Use subdued for non-selected - readable on any terminal
665                print!(
666                    "  {}{} {}{}\r\n",
667                    ansi::SUBDUED,
668                    prefix,
669                    suggestion.display,
670                    ansi::RESET
671                );
672            }
673            lines_rendered += 1;
674        }
675
676        // Print hint - use subdued for secondary text
677        print!(
678            "  {}[↑↓ navigate, Enter select, Esc cancel]{}\r\n",
679            ansi::SUBDUED,
680            ansi::RESET
681        );
682        lines_rendered += 1;
683    }
684
685    // Position cursor correctly within input (handling newlines)
686    // Calculate which line and column the cursor is on
687    let mut cursor_line = 0;
688    let mut cursor_col = prompt_len;
689
690    for (i, c) in state.text.chars().enumerate() {
691        if i >= state.cursor {
692            break;
693        }
694        if c == '\n' {
695            cursor_line += 1;
696            cursor_col = 0;
697        } else {
698            cursor_col += 1;
699            if term_width > 0 && cursor_col >= term_width {
700                cursor_line += 1;
701                cursor_col = 0;
702            }
703        }
704    }
705
706    // Move cursor from end of text to correct position
707    let lines_after_cursor = total_lines.saturating_sub(cursor_line + 1) + lines_rendered;
708    if lines_after_cursor > 0 {
709        execute!(stdout, cursor::MoveUp(lines_after_cursor as u16))?;
710    }
711    execute!(stdout, cursor::MoveToColumn(cursor_col as u16))?;
712
713    // Save the cursor line for next render's initial positioning
714    state.prev_cursor_line = cursor_line;
715
716    stdout.flush()?;
717    Ok(lines_rendered)
718}
719
720/// Clear rendered suggestion lines
721fn clear_suggestions(num_lines: usize, stdout: &mut io::Stdout) -> io::Result<()> {
722    if num_lines > 0 {
723        // Save position, clear lines below, restore
724        for _ in 0..num_lines {
725            execute!(stdout, cursor::MoveDown(1), Clear(ClearType::CurrentLine))?;
726        }
727        execute!(stdout, MoveUp(num_lines as u16))?;
728    }
729    Ok(())
730}
731
732/// Read user input with Claude Code-style @ file picker
733/// If `plan_mode` is true, shows the plan mode indicator below the prompt
734pub fn read_input_with_file_picker(
735    prompt: &str,
736    project_path: &std::path::Path,
737    plan_mode: bool,
738) -> InputResult {
739    let mut stdout = io::stdout();
740
741    // Always ensure cursor is visible at start of input (may have been hidden by progress indicator)
742    print!("{}", ansi::SHOW_CURSOR);
743    let _ = stdout.flush();
744
745    // Enable raw mode
746    if terminal::enable_raw_mode().is_err() {
747        return read_simple_input(prompt);
748    }
749
750    // Enable bracketed paste mode to detect paste vs keypress
751    let _ = execute!(stdout, EnableBracketedPaste);
752
753    // Print prompt with mode indicator inline (no separate line)
754    if plan_mode {
755        print!(
756            "{}★{} {}{}{} ",
757            ansi::ORANGE,
758            ansi::RESET,
759            ansi::SUCCESS,
760            prompt,
761            ansi::RESET
762        );
763    } else {
764        print!("{}{}{} ", ansi::SUCCESS, prompt, ansi::RESET);
765    }
766    let _ = stdout.flush();
767
768    // Create state after printing prompt so start_row is correct
769    let mut state = InputState::new(project_path.to_path_buf(), plan_mode);
770
771    let result = loop {
772        match event::read() {
773            // Handle paste events - insert all pasted text at once
774            Ok(Event::Paste(pasted_text)) => {
775                // Normalize line endings: \r\n -> \n, lone \r -> \n
776                let normalized = pasted_text.replace("\r\n", "\n").replace('\r', "\n");
777                for c in normalized.chars() {
778                    state.insert_char(c);
779                }
780                // Render after paste completes
781                state.rendered_lines = render(&mut state, prompt, &mut stdout).unwrap_or(0);
782            }
783            Ok(Event::Key(key_event)) => {
784                match key_event.code {
785                    KeyCode::Enter => {
786                        // Shift+Enter or Alt+Enter inserts newline instead of submitting
787                        if key_event.modifiers.contains(KeyModifiers::SHIFT)
788                            || key_event.modifiers.contains(KeyModifiers::ALT)
789                        {
790                            state.insert_char('\n');
791                        } else if state.showing_suggestions && state.selected >= 0 {
792                            // Accept selection, don't submit
793                            state.accept_selection();
794                        } else if !state.text.trim().is_empty() {
795                            // Submit
796                            print!("\r\n");
797                            let _ = stdout.flush();
798                            break InputResult::Submit(state.text.clone());
799                        }
800                    }
801                    KeyCode::Tab => {
802                        // Tab also accepts selection
803                        if state.showing_suggestions && state.selected >= 0 {
804                            state.accept_selection();
805                        }
806                    }
807                    KeyCode::BackTab => {
808                        // Shift+Tab toggles planning mode
809                        print!("\r\n");
810                        let _ = stdout.flush();
811                        break InputResult::TogglePlanMode;
812                    }
813                    KeyCode::Esc => {
814                        if state.showing_suggestions {
815                            state.close_suggestions();
816                        } else {
817                            print!("\r\n");
818                            let _ = stdout.flush();
819                            break InputResult::Cancel;
820                        }
821                    }
822                    KeyCode::Char('c') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
823                        // Ctrl+C always exits (consistent with standard CLI behavior)
824                        print!("\r\n");
825                        let _ = stdout.flush();
826                        break InputResult::Cancel;
827                    }
828                    KeyCode::Char('d') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
829                        print!("\r\n");
830                        let _ = stdout.flush();
831                        break InputResult::Exit;
832                    }
833                    KeyCode::Up => {
834                        if state.showing_suggestions {
835                            state.select_up();
836                        } else {
837                            state.cursor_up();
838                        }
839                    }
840                    KeyCode::Down => {
841                        if state.showing_suggestions {
842                            state.select_down();
843                        } else {
844                            state.cursor_down();
845                        }
846                    }
847                    KeyCode::Left => {
848                        state.cursor_left();
849                        // Close suggestions if cursor moves before @
850                        if let Some(start) = state.completion_start
851                            && state.cursor <= start
852                        {
853                            state.close_suggestions();
854                        }
855                    }
856                    KeyCode::Right => {
857                        state.cursor_right();
858                    }
859                    // Alt+b (Option+Left on Mac) - Move cursor to previous word
860                    KeyCode::Char('b') if key_event.modifiers.contains(KeyModifiers::ALT) => {
861                        state.cursor_word_left();
862                        state.close_suggestions();
863                    }
864                    // Alt+f (Option+Right on Mac) - Move cursor to next word
865                    KeyCode::Char('f') if key_event.modifiers.contains(KeyModifiers::ALT) => {
866                        state.cursor_word_right();
867                    }
868                    KeyCode::Home | KeyCode::Char('a')
869                        if key_event.modifiers.contains(KeyModifiers::CONTROL) =>
870                    {
871                        state.cursor_home();
872                        state.close_suggestions();
873                    }
874                    KeyCode::End | KeyCode::Char('e')
875                        if key_event.modifiers.contains(KeyModifiers::CONTROL) =>
876                    {
877                        state.cursor_end();
878                    }
879                    // Ctrl+U - Clear entire input
880                    KeyCode::Char('u') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
881                        state.clear_all();
882                    }
883                    // Ctrl+K - Delete to beginning of current line (works on all platforms)
884                    KeyCode::Char('k') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
885                        state.delete_to_line_start();
886                    }
887                    // Ctrl+Shift+Backspace - Delete to beginning of current line (cross-platform)
888                    KeyCode::Backspace
889                        if key_event.modifiers.contains(KeyModifiers::CONTROL)
890                            && key_event.modifiers.contains(KeyModifiers::SHIFT) =>
891                    {
892                        state.delete_to_line_start();
893                    }
894                    // Cmd+Backspace (Mac) - Delete to beginning of current line (if terminal passes it)
895                    KeyCode::Backspace if key_event.modifiers.contains(KeyModifiers::SUPER) => {
896                        state.delete_to_line_start();
897                    }
898                    // Ctrl+W or Alt+Backspace - Delete word left
899                    KeyCode::Char('w') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
900                        state.delete_word_left();
901                    }
902                    KeyCode::Backspace if key_event.modifiers.contains(KeyModifiers::ALT) => {
903                        state.delete_word_left();
904                    }
905                    KeyCode::Backspace if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
906                        state.delete_word_left();
907                    }
908                    // Ctrl+J - Insert newline (multi-line input)
909                    KeyCode::Char('j') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
910                        state.insert_char('\n');
911                    }
912                    KeyCode::Backspace => {
913                        state.backspace();
914                    }
915                    KeyCode::Char('\n') => {
916                        // Handle newline char that might come through during paste
917                        state.insert_char('\n');
918                    }
919                    KeyCode::Char(c) => {
920                        state.insert_char(c);
921                    }
922                    _ => {}
923                }
924
925                // Only render if no more events are pending (batches rapid input like paste)
926                // This prevents thousands of renders during paste operations
927                let should_render =
928                    !event::poll(std::time::Duration::from_millis(0)).unwrap_or(false);
929                if should_render {
930                    state.rendered_lines = render(&mut state, prompt, &mut stdout).unwrap_or(0);
931                }
932            }
933            Ok(Event::Resize(_, _)) => {
934                // Redraw on resize
935                state.rendered_lines = render(&mut state, prompt, &mut stdout).unwrap_or(0);
936            }
937            Err(_) => {
938                break InputResult::Cancel;
939            }
940            _ => {}
941        }
942    };
943
944    // Disable bracketed paste mode
945    let _ = execute!(stdout, DisableBracketedPaste);
946
947    // Disable raw mode
948    let _ = terminal::disable_raw_mode();
949
950    // Clean up any remaining rendered lines
951    if state.rendered_lines > 0 {
952        let _ = clear_suggestions(state.rendered_lines, &mut stdout);
953    }
954
955    result
956}
957
958/// Simple fallback input without raw mode
959fn read_simple_input(prompt: &str) -> InputResult {
960    print!("{}{}{} ", ansi::SUCCESS, prompt, ansi::RESET);
961    let _ = io::stdout().flush();
962
963    let mut input = String::new();
964    match io::stdin().read_line(&mut input) {
965        Ok(_) => {
966            let trimmed = input.trim();
967            if trimmed.eq_ignore_ascii_case("exit") || trimmed == "/exit" || trimmed == "/quit" {
968                InputResult::Exit
969            } else {
970                InputResult::Submit(trimmed.to_string())
971            }
972        }
973        Err(_) => InputResult::Cancel,
974    }
975}
976
977#[cfg(test)]
978mod tests {
979    use super::*;
980
981    fn new_state() -> InputState {
982        InputState::new(PathBuf::from("/tmp"), false)
983    }
984
985    #[test]
986    fn test_insert_char_basic() {
987        let mut state = new_state();
988        state.insert_char('h');
989        state.insert_char('i');
990        assert_eq!(state.text, "hi");
991        assert_eq!(state.cursor, 2);
992    }
993
994    #[test]
995    fn test_insert_char_utf8() {
996        let mut state = new_state();
997        state.insert_char('日');
998        state.insert_char('本');
999        assert_eq!(state.text, "日本");
1000        assert_eq!(state.cursor, 2);
1001    }
1002
1003    #[test]
1004    fn test_insert_char_skips_cr() {
1005        let mut state = new_state();
1006        state.insert_char('a');
1007        state.insert_char('\r');
1008        state.insert_char('b');
1009        assert_eq!(state.text, "ab");
1010    }
1011
1012    #[test]
1013    fn test_backspace_basic() {
1014        let mut state = new_state();
1015        state.insert_char('h');
1016        state.insert_char('e');
1017        state.insert_char('l');
1018        state.backspace();
1019        assert_eq!(state.text, "he");
1020        assert_eq!(state.cursor, 2);
1021    }
1022
1023    #[test]
1024    fn test_backspace_utf8() {
1025        let mut state = new_state();
1026        state.insert_char('日');
1027        state.insert_char('本');
1028        state.backspace();
1029        assert_eq!(state.text, "日");
1030        assert_eq!(state.cursor, 1);
1031    }
1032
1033    #[test]
1034    fn test_backspace_at_start() {
1035        let mut state = new_state();
1036        state.backspace(); // Should not panic
1037        assert_eq!(state.text, "");
1038        assert_eq!(state.cursor, 0);
1039    }
1040
1041    #[test]
1042    fn test_cursor_movement() {
1043        let mut state = new_state();
1044        state.insert_char('h');
1045        state.insert_char('e');
1046        state.insert_char('l');
1047        state.insert_char('l');
1048        state.insert_char('o');
1049        assert_eq!(state.cursor, 5);
1050
1051        state.cursor_left();
1052        assert_eq!(state.cursor, 4);
1053
1054        state.cursor_home();
1055        assert_eq!(state.cursor, 0);
1056
1057        state.cursor_right();
1058        assert_eq!(state.cursor, 1);
1059
1060        state.cursor_end();
1061        assert_eq!(state.cursor, 5);
1062    }
1063
1064    #[test]
1065    fn test_cursor_bounds() {
1066        let mut state = new_state();
1067        state.insert_char('a');
1068
1069        state.cursor_left();
1070        state.cursor_left(); // Should not go below 0
1071        assert_eq!(state.cursor, 0);
1072
1073        state.cursor_right();
1074        state.cursor_right(); // Should not go beyond text length
1075        assert_eq!(state.cursor, 1);
1076    }
1077
1078    #[test]
1079    fn test_char_to_byte_pos_ascii() {
1080        let mut state = new_state();
1081        state.text = "hello".to_string();
1082        assert_eq!(state.char_to_byte_pos(0), 0);
1083        assert_eq!(state.char_to_byte_pos(2), 2);
1084        assert_eq!(state.char_to_byte_pos(5), 5);
1085    }
1086
1087    #[test]
1088    fn test_char_to_byte_pos_utf8() {
1089        let mut state = new_state();
1090        state.text = "日本語".to_string(); // Each char is 3 bytes
1091        assert_eq!(state.char_to_byte_pos(0), 0);
1092        assert_eq!(state.char_to_byte_pos(1), 3);
1093        assert_eq!(state.char_to_byte_pos(2), 6);
1094        assert_eq!(state.char_to_byte_pos(3), 9);
1095    }
1096
1097    #[test]
1098    fn test_clear_all() {
1099        let mut state = new_state();
1100        state.insert_char('h');
1101        state.insert_char('e');
1102        state.insert_char('l');
1103        state.clear_all();
1104        assert_eq!(state.text, "");
1105        assert_eq!(state.cursor, 0);
1106    }
1107
1108    #[test]
1109    fn test_delete_word_left() {
1110        let mut state = new_state();
1111        for c in "hello world".chars() {
1112            state.insert_char(c);
1113        }
1114        state.delete_word_left();
1115        assert_eq!(state.text, "hello ");
1116        assert_eq!(state.cursor, 6);
1117    }
1118
1119    #[test]
1120    fn test_multiline_cursor_navigation() {
1121        let mut state = new_state();
1122        // "ab\ncd"
1123        for c in "ab".chars() {
1124            state.insert_char(c);
1125        }
1126        state.insert_char('\n');
1127        for c in "cd".chars() {
1128            state.insert_char(c);
1129        }
1130        assert_eq!(state.cursor, 5); // at end
1131
1132        state.cursor_up();
1133        assert_eq!(state.cursor, 2); // end of first line "ab"
1134
1135        state.cursor_down();
1136        assert_eq!(state.cursor, 5); // back to end
1137    }
1138
1139    #[test]
1140    fn test_get_filter_at_symbol() {
1141        let mut state = new_state();
1142        state.text = "@src".to_string();
1143        state.cursor = 4;
1144        state.completion_start = Some(0);
1145        assert_eq!(state.get_filter(), Some("src".to_string()));
1146    }
1147
1148    #[test]
1149    fn test_get_filter_no_completion() {
1150        let mut state = new_state();
1151        state.text = "hello".to_string();
1152        state.cursor = 5;
1153        assert_eq!(state.get_filter(), None);
1154    }
1155
1156    #[test]
1157    fn test_cursor_word_left() {
1158        let mut state = new_state();
1159        state.text = "hello world test".to_string();
1160        state.cursor = 16; // at end
1161
1162        state.cursor_word_left();
1163        assert_eq!(state.cursor, 12); // start of "test"
1164
1165        state.cursor_word_left();
1166        assert_eq!(state.cursor, 6); // start of "world"
1167
1168        state.cursor_word_left();
1169        assert_eq!(state.cursor, 0); // start of "hello"
1170
1171        state.cursor_word_left();
1172        assert_eq!(state.cursor, 0); // still at start
1173    }
1174
1175    #[test]
1176    fn test_cursor_word_right() {
1177        let mut state = new_state();
1178        state.text = "hello world test".to_string();
1179        state.cursor = 0; // at start
1180
1181        state.cursor_word_right();
1182        assert_eq!(state.cursor, 6); // start of "world"
1183
1184        state.cursor_word_right();
1185        assert_eq!(state.cursor, 12); // start of "test"
1186
1187        state.cursor_word_right();
1188        assert_eq!(state.cursor, 16); // end of text
1189
1190        state.cursor_word_right();
1191        assert_eq!(state.cursor, 16); // still at end
1192    }
1193
1194    #[test]
1195    fn test_cursor_word_movement_mid_word() {
1196        let mut state = new_state();
1197        state.text = "hello world".to_string();
1198        state.cursor = 8; // middle of "world"
1199
1200        state.cursor_word_left();
1201        assert_eq!(state.cursor, 6); // start of "world"
1202
1203        state.cursor = 3; // middle of "hello"
1204        state.cursor_word_right();
1205        assert_eq!(state.cursor, 6); // start of "world"
1206    }
1207}