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    /// Whether in plan mode (shows ★ indicator)
62    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    /// Insert character at cursor
82    fn insert_char(&mut self, c: char) {
83        // Skip carriage returns, keep newlines for multi-line support
84        if c == '\r' {
85            return;
86        }
87
88        // Insert at cursor position
89        let byte_pos = self.char_to_byte_pos(self.cursor);
90        self.text.insert(byte_pos, c);
91        self.cursor += 1;
92
93        // Check if we should trigger completion
94        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            // Slash command at start
103            self.completion_start = Some(0);
104            self.refresh_suggestions();
105        } else if c.is_whitespace() {
106            // Space closes completion
107            self.close_suggestions();
108        } else if self.completion_start.is_some() {
109            // Continue filtering
110            self.refresh_suggestions();
111        }
112    }
113
114    /// Delete character before cursor
115    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            // Check if we deleted the @ trigger
123            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    /// Delete word before cursor (Ctrl+Backspace / Cmd+Delete / Alt+Backspace)
134    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        // Skip whitespace going backwards
143        while new_cursor > 0 && chars[new_cursor - 1].is_whitespace() {
144            new_cursor -= 1;
145        }
146
147        // Skip word characters going backwards
148        while new_cursor > 0 && !chars[new_cursor - 1].is_whitespace() {
149            new_cursor -= 1;
150        }
151
152        // Delete from new_cursor to current cursor
153        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        // Update suggestions if active
159        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    /// Clear entire input (Ctrl+U)
169    fn clear_all(&mut self) {
170        self.text.clear();
171        self.cursor = 0;
172        self.close_suggestions();
173    }
174
175    /// Delete from cursor to beginning of current line (Cmd+Backspace)
176    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        // Find the previous newline or start of text
184        let mut line_start = self.cursor;
185        while line_start > 0 && chars[line_start - 1] != '\n' {
186            line_start -= 1;
187        }
188
189        // If cursor is at line start, delete the newline to join with previous line
190        if line_start == self.cursor && self.cursor > 0 {
191            line_start -= 1;
192        }
193
194        // Delete from line_start to cursor
195        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    /// Convert character position to byte position
204    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    /// Get the current filter text (after @ or /)
212    fn get_filter(&self) -> Option<String> {
213        self.completion_start.map(|start| {
214            let filter_start = start + 1; // Skip the @ or /
215            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    /// Refresh suggestions based on current filter
224    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    /// Search for files matching filter
240    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        // Sort: directories first, then by path length
247        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    /// Walk directory tree for matching files
260    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            // Skip hidden files (except some)
277            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    /// Search for slash commands matching filter
307    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    /// Close suggestions dropdown
325    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    /// Move selection up
333    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    /// Move selection down
342    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    /// Accept the current selection
351    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                    // Replace @filter with @value
356                    let before = self.text.chars().take(start).collect::<String>();
357                    let after = self.text.chars().skip(self.cursor).collect::<String>();
358
359                    // For files, use @path format; for commands, use /command
360                    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    /// Move cursor left
377    fn cursor_left(&mut self) {
378        if self.cursor > 0 {
379            self.cursor -= 1;
380        }
381    }
382
383    /// Move cursor right
384    fn cursor_right(&mut self) {
385        if self.cursor < self.text.chars().count() {
386            self.cursor += 1;
387        }
388    }
389
390    /// Move cursor to start
391    fn cursor_home(&mut self) {
392        self.cursor = 0;
393    }
394
395    /// Move cursor to end
396    fn cursor_end(&mut self) {
397        self.cursor = self.text.chars().count();
398    }
399
400    /// Move cursor up one line
401    fn cursor_up(&mut self) {
402        let chars: Vec<char> = self.text.chars().collect();
403        if self.cursor == 0 {
404            return;
405        }
406
407        // Find the start of the current line
408        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 we're on the first line, can't go up
414        if current_line_start == 0 {
415            return;
416        }
417
418        // Find the column position on current line
419        let col = self.cursor - current_line_start;
420
421        // Find the start of the previous line
422        let prev_line_end = current_line_start - 1; // Position of the \n
423        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        // Calculate the length of the previous line
429        let prev_line_len = prev_line_end - prev_line_start;
430
431        // Move cursor to same column on previous line (or end if line is shorter)
432        self.cursor = prev_line_start + col.min(prev_line_len);
433    }
434
435    /// Move cursor down one line
436    fn cursor_down(&mut self) {
437        let chars: Vec<char> = self.text.chars().collect();
438        let text_len = chars.len();
439
440        // Find the start of the current line
441        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        // Find the column position on current line
447        let col = self.cursor - current_line_start;
448
449        // Find the end of the current line (the \n or end of text)
450        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 we're on the last line, can't go down
456        if current_line_end >= text_len {
457            return;
458        }
459
460        // Find the start of the next line
461        let next_line_start = current_line_end + 1;
462
463        // Find the end of the next line
464        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        // Calculate the length of the next line
470        let next_line_len = next_line_end - next_line_start;
471
472        // Move cursor to same column on next line (or end if line is shorter)
473        self.cursor = next_line_start + col.min(next_line_len);
474    }
475}
476
477/// Render the input UI with multi-line support
478fn render(state: &mut InputState, prompt: &str, stdout: &mut io::Stdout) -> io::Result<usize> {
479    // Get terminal width
480    let (term_width, _) = terminal::size().unwrap_or((80, 24));
481    let term_width = term_width as usize;
482
483    // Calculate prompt length (include ★ prefix if in plan mode)
484    let mode_prefix_len = if state.plan_mode { 2 } else { 0 }; // "★ " = 2 chars
485    let prompt_len = prompt.len() + 1 + mode_prefix_len; // +1 for space after prompt
486
487    // Move up to clear previous rendered lines, then to column 0
488    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    // Clear from cursor to end of screen
494    execute!(stdout, Clear(ClearType::FromCursorDown))?;
495
496    // Print prompt and input text with mode indicator if in plan mode
497    // In raw mode, \n doesn't return to column 0, so we need \r\n
498    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    // Calculate how many lines the text spans (counting newlines + wrapping)
507    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    // Render suggestions below if active
525    let mut lines_rendered = 0;
526    if state.showing_suggestions && !state.suggestions.is_empty() {
527        // Move to next line for suggestions (use \r\n in raw mode)
528        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 hint
548        print!("  {}[↑↓ navigate, Enter select, Esc cancel]{}\r\n", ansi::DIM, ansi::RESET);
549        lines_rendered += 1;
550    }
551
552    // Position cursor correctly within input (handling newlines)
553    // Calculate which line and column the cursor is on
554    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    // Move cursor from end of text to correct position
574    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
584/// Clear rendered suggestion lines
585fn clear_suggestions(num_lines: usize, stdout: &mut io::Stdout) -> io::Result<()> {
586    if num_lines > 0 {
587        // Save position, clear lines below, restore
588        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
599/// Read user input with Claude Code-style @ file picker
600/// If `plan_mode` is true, shows the plan mode indicator below the prompt
601pub fn read_input_with_file_picker(prompt: &str, project_path: &PathBuf, plan_mode: bool) -> InputResult {
602    let mut stdout = io::stdout();
603
604    // Enable raw mode
605    if terminal::enable_raw_mode().is_err() {
606        return read_simple_input(prompt);
607    }
608
609    // Enable bracketed paste mode to detect paste vs keypress
610    let _ = execute!(stdout, EnableBracketedPaste);
611
612    // Print prompt with mode indicator inline (no separate line)
613    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    // Create state after printing prompt so start_row is correct
621    let mut state = InputState::new(project_path.clone(), plan_mode);
622
623    let result = loop {
624        match event::read() {
625            // Handle paste events - insert all pasted text at once
626            Ok(Event::Paste(pasted_text)) => {
627                // Normalize line endings: \r\n -> \n, lone \r -> \n
628                let normalized = pasted_text.replace("\r\n", "\n").replace('\r', "\n");
629                for c in normalized.chars() {
630                    state.insert_char(c);
631                }
632                // Render after paste completes
633                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                        // Shift+Enter or Alt+Enter inserts newline instead of submitting
639                        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                            // Accept selection, don't submit
644                            state.accept_selection();
645                        } else if !state.text.trim().is_empty() {
646                            // Submit
647                            print!("\r\n");
648                            let _ = stdout.flush();
649                            break InputResult::Submit(state.text.clone());
650                        }
651                    }
652                    KeyCode::Tab => {
653                        // Tab also accepts selection
654                        if state.showing_suggestions && state.selected >= 0 {
655                            state.accept_selection();
656                        }
657                    }
658                    KeyCode::BackTab => {
659                        // Shift+Tab toggles planning mode
660                        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                        // Ctrl+C always exits (consistent with standard CLI behavior)
675                        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                        // Close suggestions if cursor moves before @
701                        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                    // Ctrl+U - Clear entire input
718                    KeyCode::Char('u') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
719                        state.clear_all();
720                    }
721                    // Ctrl+K - Delete to beginning of current line (works on all platforms)
722                    KeyCode::Char('k') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
723                        state.delete_to_line_start();
724                    }
725                    // Ctrl+Shift+Backspace - Delete to beginning of current line (cross-platform)
726                    KeyCode::Backspace if key_event.modifiers.contains(KeyModifiers::CONTROL)
727                        && key_event.modifiers.contains(KeyModifiers::SHIFT) => {
728                        state.delete_to_line_start();
729                    }
730                    // Cmd+Backspace (Mac) - Delete to beginning of current line (if terminal passes it)
731                    KeyCode::Backspace if key_event.modifiers.contains(KeyModifiers::SUPER) => {
732                        state.delete_to_line_start();
733                    }
734                    // Ctrl+W or Alt+Backspace - Delete word left
735                    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                    // Ctrl+J - Insert newline (multi-line input)
745                    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                        // Handle newline char that might come through during paste
753                        state.insert_char('\n');
754                    }
755                    KeyCode::Char(c) => {
756                        state.insert_char(c);
757                    }
758                    _ => {}
759                }
760
761                // Only render if no more events are pending (batches rapid input like paste)
762                // This prevents thousands of renders during paste operations
763                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                // Redraw on resize
770                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    // Disable bracketed paste mode
780    let _ = execute!(stdout, DisableBracketedPaste);
781
782    // Disable raw mode
783    let _ = terminal::disable_raw_mode();
784
785    // Clean up any remaining rendered lines
786    if state.rendered_lines > 0 {
787        let _ = clear_suggestions(state.rendered_lines, &mut stdout);
788    }
789
790    result
791}
792
793/// Simple fallback input without raw mode
794fn 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}