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}
30
31/// Suggestion item
32#[derive(Clone)]
33struct Suggestion {
34    display: String,
35    value: String,
36    is_dir: bool,
37}
38
39/// Input state
40struct InputState {
41    /// Current input text
42    text: String,
43    /// Cursor position in text (character index)
44    cursor: usize,
45    /// Current suggestions
46    suggestions: Vec<Suggestion>,
47    /// Selected suggestion index (-1 = none selected)
48    selected: i32,
49    /// Whether suggestions dropdown is visible
50    showing_suggestions: bool,
51    /// Start position of current completion (@ position)
52    completion_start: Option<usize>,
53    /// Project path for file searches
54    project_path: PathBuf,
55    /// Number of lines rendered for suggestions (for cleanup)
56    rendered_lines: usize,
57    /// Number of wrapped lines the input text occupied in last render
58    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    /// Insert character at cursor
77    fn insert_char(&mut self, c: char) {
78        // Skip carriage returns, keep newlines for multi-line support
79        if c == '\r' {
80            return;
81        }
82
83        // Insert at cursor position
84        let byte_pos = self.char_to_byte_pos(self.cursor);
85        self.text.insert(byte_pos, c);
86        self.cursor += 1;
87
88        // Check if we should trigger completion
89        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            // Slash command at start
98            self.completion_start = Some(0);
99            self.refresh_suggestions();
100        } else if c.is_whitespace() {
101            // Space closes completion
102            self.close_suggestions();
103        } else if self.completion_start.is_some() {
104            // Continue filtering
105            self.refresh_suggestions();
106        }
107    }
108
109    /// Delete character before cursor
110    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            // Check if we deleted the @ trigger
118            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    /// Delete word before cursor (Ctrl+Backspace / Cmd+Delete / Alt+Backspace)
129    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        // Skip whitespace going backwards
138        while new_cursor > 0 && chars[new_cursor - 1].is_whitespace() {
139            new_cursor -= 1;
140        }
141
142        // Skip word characters going backwards
143        while new_cursor > 0 && !chars[new_cursor - 1].is_whitespace() {
144            new_cursor -= 1;
145        }
146
147        // Delete from new_cursor to current cursor
148        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        // Update suggestions if active
154        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    /// Clear entire input (Ctrl+U)
164    fn clear_all(&mut self) {
165        self.text.clear();
166        self.cursor = 0;
167        self.close_suggestions();
168    }
169
170    /// Delete from cursor to beginning of current line (Cmd+Backspace)
171    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        // Find the previous newline or start of text
179        let mut line_start = self.cursor;
180        while line_start > 0 && chars[line_start - 1] != '\n' {
181            line_start -= 1;
182        }
183
184        // If cursor is at line start, delete the newline to join with previous line
185        if line_start == self.cursor && self.cursor > 0 {
186            line_start -= 1;
187        }
188
189        // Delete from line_start to cursor
190        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    /// Convert character position to byte position
199    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    /// Get the current filter text (after @ or /)
207    fn get_filter(&self) -> Option<String> {
208        self.completion_start.map(|start| {
209            let filter_start = start + 1; // Skip the @ or /
210            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    /// Refresh suggestions based on current filter
219    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    /// Search for files matching filter
235    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        // Sort: directories first, then by path length
242        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    /// Walk directory tree for matching files
255    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            // Skip hidden files (except some)
272            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    /// Search for slash commands matching filter
302    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    /// Close suggestions dropdown
320    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    /// Move selection up
328    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    /// Move selection down
337    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    /// Accept the current selection
346    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                    // Replace @filter with @value
351                    let before = self.text.chars().take(start).collect::<String>();
352                    let after = self.text.chars().skip(self.cursor).collect::<String>();
353
354                    // For files, use @path format; for commands, use /command
355                    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    /// Move cursor left
372    fn cursor_left(&mut self) {
373        if self.cursor > 0 {
374            self.cursor -= 1;
375        }
376    }
377
378    /// Move cursor right
379    fn cursor_right(&mut self) {
380        if self.cursor < self.text.chars().count() {
381            self.cursor += 1;
382        }
383    }
384
385    /// Move cursor to start
386    fn cursor_home(&mut self) {
387        self.cursor = 0;
388    }
389
390    /// Move cursor to end
391    fn cursor_end(&mut self) {
392        self.cursor = self.text.chars().count();
393    }
394
395    /// Move cursor up one line
396    fn cursor_up(&mut self) {
397        let chars: Vec<char> = self.text.chars().collect();
398        if self.cursor == 0 {
399            return;
400        }
401
402        // Find the start of the current line
403        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 we're on the first line, can't go up
409        if current_line_start == 0 {
410            return;
411        }
412
413        // Find the column position on current line
414        let col = self.cursor - current_line_start;
415
416        // Find the start of the previous line
417        let prev_line_end = current_line_start - 1; // Position of the \n
418        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        // Calculate the length of the previous line
424        let prev_line_len = prev_line_end - prev_line_start;
425
426        // Move cursor to same column on previous line (or end if line is shorter)
427        self.cursor = prev_line_start + col.min(prev_line_len);
428    }
429
430    /// Move cursor down one line
431    fn cursor_down(&mut self) {
432        let chars: Vec<char> = self.text.chars().collect();
433        let text_len = chars.len();
434
435        // Find the start of the current line
436        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        // Find the column position on current line
442        let col = self.cursor - current_line_start;
443
444        // Find the end of the current line (the \n or end of text)
445        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 we're on the last line, can't go down
451        if current_line_end >= text_len {
452            return;
453        }
454
455        // Find the start of the next line
456        let next_line_start = current_line_end + 1;
457
458        // Find the end of the next line
459        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        // Calculate the length of the next line
465        let next_line_len = next_line_end - next_line_start;
466
467        // Move cursor to same column on next line (or end if line is shorter)
468        self.cursor = next_line_start + col.min(next_line_len);
469    }
470}
471
472/// Render the input UI with multi-line support
473fn render(state: &mut InputState, prompt: &str, stdout: &mut io::Stdout) -> io::Result<usize> {
474    // Get terminal width
475    let (term_width, _) = terminal::size().unwrap_or((80, 24));
476    let term_width = term_width as usize;
477
478    // Calculate prompt length
479    let prompt_len = prompt.len() + 1; // +1 for space
480
481    // Move up to clear previous rendered lines, then to column 0
482    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    // Clear from cursor to end of screen
488    execute!(stdout, Clear(ClearType::FromCursorDown))?;
489
490    // Print prompt and input text
491    // In raw mode, \n doesn't return to column 0, so we need \r\n
492    let display_text = state.text.replace('\n', "\r\n");
493    print!("{}{}{} {}", ansi::SUCCESS, prompt, ansi::RESET, display_text);
494    stdout.flush()?;
495
496    // Calculate how many lines the text spans (counting newlines + wrapping)
497    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    // Render suggestions below if active
515    let mut lines_rendered = 0;
516    if state.showing_suggestions && !state.suggestions.is_empty() {
517        // Move to next line for suggestions (use \r\n in raw mode)
518        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 hint
538        print!("  {}[↑↓ navigate, Enter select, Esc cancel]{}\r\n", ansi::DIM, ansi::RESET);
539        lines_rendered += 1;
540    }
541
542    // Position cursor correctly within input (handling newlines)
543    // Calculate which line and column the cursor is on
544    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    // Move cursor from end of text to correct position
564    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
574/// Clear rendered suggestion lines
575fn clear_suggestions(num_lines: usize, stdout: &mut io::Stdout) -> io::Result<()> {
576    if num_lines > 0 {
577        // Save position, clear lines below, restore
578        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
589/// Read user input with Claude Code-style @ file picker
590pub fn read_input_with_file_picker(prompt: &str, project_path: &PathBuf) -> InputResult {
591    let mut stdout = io::stdout();
592
593    // Enable raw mode
594    if terminal::enable_raw_mode().is_err() {
595        return read_simple_input(prompt);
596    }
597
598    // Enable bracketed paste mode to detect paste vs keypress
599    let _ = execute!(stdout, EnableBracketedPaste);
600
601    // Print initial prompt and capture start row for absolute positioning
602    print!("{}{}{} ", ansi::SUCCESS, prompt, ansi::RESET);
603    let _ = stdout.flush();
604
605    // Create state after printing prompt so start_row is correct
606    let mut state = InputState::new(project_path.clone());
607
608    let result = loop {
609        match event::read() {
610            // Handle paste events - insert all pasted text at once
611            Ok(Event::Paste(pasted_text)) => {
612                // Normalize line endings: \r\n -> \n, lone \r -> \n
613                let normalized = pasted_text.replace("\r\n", "\n").replace('\r', "\n");
614                for c in normalized.chars() {
615                    state.insert_char(c);
616                }
617                // Render after paste completes
618                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                        // Shift+Enter or Alt+Enter inserts newline instead of submitting
624                        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                            // Accept selection, don't submit
629                            state.accept_selection();
630                        } else if !state.text.trim().is_empty() {
631                            // Submit
632                            print!("\r\n");
633                            let _ = stdout.flush();
634                            break InputResult::Submit(state.text.clone());
635                        }
636                    }
637                    KeyCode::Tab => {
638                        // Tab also accepts selection
639                        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                            // Clear input
655                            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                        // Close suggestions if cursor moves before @
686                        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                    // Ctrl+U - Clear entire input
703                    KeyCode::Char('u') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
704                        state.clear_all();
705                    }
706                    // Ctrl+K - Delete to beginning of current line (works on all platforms)
707                    KeyCode::Char('k') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
708                        state.delete_to_line_start();
709                    }
710                    // Ctrl+Shift+Backspace - Delete to beginning of current line (cross-platform)
711                    KeyCode::Backspace if key_event.modifiers.contains(KeyModifiers::CONTROL)
712                        && key_event.modifiers.contains(KeyModifiers::SHIFT) => {
713                        state.delete_to_line_start();
714                    }
715                    // Cmd+Backspace (Mac) - Delete to beginning of current line (if terminal passes it)
716                    KeyCode::Backspace if key_event.modifiers.contains(KeyModifiers::SUPER) => {
717                        state.delete_to_line_start();
718                    }
719                    // Ctrl+W or Alt+Backspace - Delete word left
720                    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                    // Ctrl+J - Insert newline (multi-line input)
730                    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                        // Handle newline char that might come through during paste
738                        state.insert_char('\n');
739                    }
740                    KeyCode::Char(c) => {
741                        state.insert_char(c);
742                    }
743                    _ => {}
744                }
745
746                // Only render if no more events are pending (batches rapid input like paste)
747                // This prevents thousands of renders during paste operations
748                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                // Redraw on resize
755                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    // Disable bracketed paste mode
765    let _ = execute!(stdout, DisableBracketedPaste);
766
767    // Disable raw mode
768    let _ = terminal::disable_raw_mode();
769
770    // Clean up any remaining rendered lines
771    if state.rendered_lines > 0 {
772        let _ = clear_suggestions(state.rendered_lines, &mut stdout);
773    }
774
775    result
776}
777
778/// Simple fallback input without raw mode
779fn 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}