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
97                    .text
98                    .chars()
99                    .nth(self.cursor - 2)
100                    .map(|c| c.is_whitespace())
101                    .unwrap_or(false);
102            if valid_trigger {
103                self.completion_start = Some(self.cursor - 1);
104                self.refresh_suggestions();
105            }
106        } else if c == '/' && self.cursor == 1 {
107            // Slash command at start
108            self.completion_start = Some(0);
109            self.refresh_suggestions();
110        } else if c.is_whitespace() {
111            // Space closes completion
112            self.close_suggestions();
113        } else if self.completion_start.is_some() {
114            // Continue filtering
115            self.refresh_suggestions();
116        }
117    }
118
119    /// Delete character before cursor
120    fn backspace(&mut self) {
121        if self.cursor > 0 {
122            let byte_pos = self.char_to_byte_pos(self.cursor - 1);
123            let next_byte_pos = self.char_to_byte_pos(self.cursor);
124            self.text.replace_range(byte_pos..next_byte_pos, "");
125            self.cursor -= 1;
126
127            // Check if we deleted the @ trigger
128            if let Some(start) = self.completion_start {
129                if self.cursor <= start {
130                    self.close_suggestions();
131                } else {
132                    self.refresh_suggestions();
133                }
134            }
135        }
136    }
137
138    /// Delete word before cursor (Ctrl+Backspace / Cmd+Delete / Alt+Backspace)
139    fn delete_word_left(&mut self) {
140        if self.cursor == 0 {
141            return;
142        }
143
144        let chars: Vec<char> = self.text.chars().collect();
145        let mut new_cursor = self.cursor;
146
147        // Skip whitespace going backwards
148        while new_cursor > 0 && chars[new_cursor - 1].is_whitespace() {
149            new_cursor -= 1;
150        }
151
152        // Skip word characters going backwards
153        while new_cursor > 0 && !chars[new_cursor - 1].is_whitespace() {
154            new_cursor -= 1;
155        }
156
157        // Delete from new_cursor to current cursor
158        let start_byte = self.char_to_byte_pos(new_cursor);
159        let end_byte = self.char_to_byte_pos(self.cursor);
160        self.text.replace_range(start_byte..end_byte, "");
161        self.cursor = new_cursor;
162
163        // Update suggestions if active
164        if let Some(start) = self.completion_start {
165            if self.cursor <= start {
166                self.close_suggestions();
167            } else {
168                self.refresh_suggestions();
169            }
170        }
171    }
172
173    /// Clear entire input (Ctrl+U)
174    fn clear_all(&mut self) {
175        self.text.clear();
176        self.cursor = 0;
177        self.close_suggestions();
178    }
179
180    /// Delete from cursor to beginning of current line (Cmd+Backspace)
181    fn delete_to_line_start(&mut self) {
182        if self.cursor == 0 {
183            return;
184        }
185
186        let chars: Vec<char> = self.text.chars().collect();
187
188        // Find the previous newline or start of text
189        let mut line_start = self.cursor;
190        while line_start > 0 && chars[line_start - 1] != '\n' {
191            line_start -= 1;
192        }
193
194        // If cursor is at line start, delete the newline to join with previous line
195        if line_start == self.cursor && self.cursor > 0 {
196            line_start -= 1;
197        }
198
199        // Delete from line_start to cursor
200        let start_byte = self.char_to_byte_pos(line_start);
201        let end_byte = self.char_to_byte_pos(self.cursor);
202        self.text.replace_range(start_byte..end_byte, "");
203        self.cursor = line_start;
204
205        self.close_suggestions();
206    }
207
208    /// Convert character position to byte position
209    fn char_to_byte_pos(&self, char_pos: usize) -> usize {
210        self.text
211            .char_indices()
212            .nth(char_pos)
213            .map(|(i, _)| i)
214            .unwrap_or(self.text.len())
215    }
216
217    /// Get the current filter text (after @ or /)
218    fn get_filter(&self) -> Option<String> {
219        self.completion_start.map(|start| {
220            let filter_start = start + 1; // Skip the @ or /
221            if filter_start <= self.cursor {
222                self.text
223                    .chars()
224                    .skip(filter_start)
225                    .take(self.cursor - filter_start)
226                    .collect()
227            } else {
228                String::new()
229            }
230        })
231    }
232
233    /// Refresh suggestions based on current filter
234    fn refresh_suggestions(&mut self) {
235        let filter = self.get_filter().unwrap_or_default();
236        let trigger = self
237            .completion_start
238            .and_then(|pos| self.text.chars().nth(pos));
239
240        self.suggestions = match trigger {
241            Some('@') => self.search_files(&filter),
242            Some('/') => self.search_commands(&filter),
243            _ => Vec::new(),
244        };
245
246        self.showing_suggestions = !self.suggestions.is_empty();
247        self.selected = if self.showing_suggestions { 0 } else { -1 };
248    }
249
250    /// Search for files matching filter
251    fn search_files(&self, filter: &str) -> Vec<Suggestion> {
252        let mut results = Vec::new();
253        let filter_lower = filter.to_lowercase();
254
255        self.walk_dir(
256            &self.project_path.clone(),
257            &filter_lower,
258            &mut results,
259            0,
260            4,
261        );
262
263        // Sort: directories first, then by path length
264        results.sort_by(|a, b| match (a.is_dir, b.is_dir) {
265            (true, false) => std::cmp::Ordering::Less,
266            (false, true) => std::cmp::Ordering::Greater,
267            _ => a.value.len().cmp(&b.value.len()),
268        });
269
270        results.truncate(8);
271        results
272    }
273
274    /// Walk directory tree for matching files
275    fn walk_dir(
276        &self,
277        dir: &PathBuf,
278        filter: &str,
279        results: &mut Vec<Suggestion>,
280        depth: usize,
281        max_depth: usize,
282    ) {
283        if depth > max_depth || results.len() >= 20 {
284            return;
285        }
286
287        let skip_dirs = [
288            "node_modules",
289            ".git",
290            "target",
291            "__pycache__",
292            ".venv",
293            "venv",
294            "dist",
295            "build",
296            ".next",
297        ];
298
299        let entries = match std::fs::read_dir(dir) {
300            Ok(e) => e,
301            Err(_) => return,
302        };
303
304        for entry in entries.flatten() {
305            let path = entry.path();
306            let file_name = entry.file_name().to_string_lossy().to_string();
307
308            // Skip hidden files (except some)
309            if file_name.starts_with('.')
310                && !file_name.starts_with(".env")
311                && file_name != ".gitignore"
312            {
313                continue;
314            }
315
316            let rel_path = path
317                .strip_prefix(&self.project_path)
318                .map(|p| p.to_string_lossy().to_string())
319                .unwrap_or_else(|_| file_name.clone());
320
321            let is_dir = path.is_dir();
322
323            if filter.is_empty()
324                || rel_path.to_lowercase().contains(filter)
325                || file_name.to_lowercase().contains(filter)
326            {
327                let display = if is_dir {
328                    format!("{}/", rel_path)
329                } else {
330                    rel_path.clone()
331                };
332                results.push(Suggestion {
333                    display: display.clone(),
334                    value: display,
335                    is_dir,
336                });
337            }
338
339            if is_dir && !skip_dirs.contains(&file_name.as_str()) {
340                self.walk_dir(&path, filter, results, depth + 1, max_depth);
341            }
342        }
343    }
344
345    /// Search for slash commands matching filter
346    fn search_commands(&self, filter: &str) -> Vec<Suggestion> {
347        let filter_lower = filter.to_lowercase();
348
349        SLASH_COMMANDS
350            .iter()
351            .filter(|cmd| {
352                cmd.name.to_lowercase().starts_with(&filter_lower)
353                    || cmd
354                        .alias
355                        .map(|a| a.to_lowercase().starts_with(&filter_lower))
356                        .unwrap_or(false)
357            })
358            .take(8)
359            .map(|cmd| Suggestion {
360                display: format!("/{:<12} {}", cmd.name, cmd.description),
361                value: format!("/{}", cmd.name),
362                is_dir: false,
363            })
364            .collect()
365    }
366
367    /// Close suggestions dropdown
368    fn close_suggestions(&mut self) {
369        self.showing_suggestions = false;
370        self.suggestions.clear();
371        self.selected = -1;
372        self.completion_start = None;
373    }
374
375    /// Move selection up
376    fn select_up(&mut self) {
377        if self.showing_suggestions && !self.suggestions.is_empty() && self.selected > 0 {
378            self.selected -= 1;
379        }
380    }
381
382    /// Move selection down
383    fn select_down(&mut self) {
384        if self.showing_suggestions
385            && !self.suggestions.is_empty()
386            && self.selected < self.suggestions.len() as i32 - 1
387        {
388            self.selected += 1;
389        }
390    }
391
392    /// Accept the current selection
393    fn accept_selection(&mut self) -> bool {
394        if self.showing_suggestions
395            && self.selected >= 0
396            && let Some(suggestion) = self.suggestions.get(self.selected as usize)
397        {
398            if let Some(start) = self.completion_start {
399                // Replace @filter with @value
400                let before = self.text.chars().take(start).collect::<String>();
401                let after = self.text.chars().skip(self.cursor).collect::<String>();
402
403                // For files, use @path format; for commands, use /command
404                let replacement = if suggestion.value.starts_with('/') {
405                    format!("{} ", suggestion.value)
406                } else {
407                    format!("@{} ", suggestion.value)
408                };
409
410                self.text = format!("{}{}{}", before, replacement, after);
411                self.cursor = before.len() + replacement.len();
412            }
413            self.close_suggestions();
414            return true;
415        }
416        false
417    }
418
419    /// Move cursor left
420    fn cursor_left(&mut self) {
421        if self.cursor > 0 {
422            self.cursor -= 1;
423        }
424    }
425
426    /// Move cursor right
427    fn cursor_right(&mut self) {
428        if self.cursor < self.text.chars().count() {
429            self.cursor += 1;
430        }
431    }
432
433    /// Move cursor to start
434    fn cursor_home(&mut self) {
435        self.cursor = 0;
436    }
437
438    /// Move cursor to end
439    fn cursor_end(&mut self) {
440        self.cursor = self.text.chars().count();
441    }
442
443    /// Move cursor up one line
444    fn cursor_up(&mut self) {
445        let chars: Vec<char> = self.text.chars().collect();
446        if self.cursor == 0 {
447            return;
448        }
449
450        // Find the start of the current line
451        let mut current_line_start = self.cursor;
452        while current_line_start > 0 && chars[current_line_start - 1] != '\n' {
453            current_line_start -= 1;
454        }
455
456        // If we're on the first line, can't go up
457        if current_line_start == 0 {
458            return;
459        }
460
461        // Find the column position on current line
462        let col = self.cursor - current_line_start;
463
464        // Find the start of the previous line
465        let prev_line_end = current_line_start - 1; // Position of the \n
466        let mut prev_line_start = prev_line_end;
467        while prev_line_start > 0 && chars[prev_line_start - 1] != '\n' {
468            prev_line_start -= 1;
469        }
470
471        // Calculate the length of the previous line
472        let prev_line_len = prev_line_end - prev_line_start;
473
474        // Move cursor to same column on previous line (or end if line is shorter)
475        self.cursor = prev_line_start + col.min(prev_line_len);
476    }
477
478    /// Move cursor down one line
479    fn cursor_down(&mut self) {
480        let chars: Vec<char> = self.text.chars().collect();
481        let text_len = chars.len();
482
483        // Find the start of the current line
484        let mut current_line_start = self.cursor;
485        while current_line_start > 0 && chars[current_line_start - 1] != '\n' {
486            current_line_start -= 1;
487        }
488
489        // Find the column position on current line
490        let col = self.cursor - current_line_start;
491
492        // Find the end of the current line (the \n or end of text)
493        let mut current_line_end = self.cursor;
494        while current_line_end < text_len && chars[current_line_end] != '\n' {
495            current_line_end += 1;
496        }
497
498        // If we're on the last line, can't go down
499        if current_line_end >= text_len {
500            return;
501        }
502
503        // Find the start of the next line
504        let next_line_start = current_line_end + 1;
505
506        // Find the end of the next line
507        let mut next_line_end = next_line_start;
508        while next_line_end < text_len && chars[next_line_end] != '\n' {
509            next_line_end += 1;
510        }
511
512        // Calculate the length of the next line
513        let next_line_len = next_line_end - next_line_start;
514
515        // Move cursor to same column on next line (or end if line is shorter)
516        self.cursor = next_line_start + col.min(next_line_len);
517    }
518}
519
520/// Render the input UI with multi-line support
521fn render(state: &mut InputState, prompt: &str, stdout: &mut io::Stdout) -> io::Result<usize> {
522    // Get terminal width
523    let (term_width, _) = terminal::size().unwrap_or((80, 24));
524    let term_width = term_width as usize;
525
526    // Calculate prompt length (include ★ prefix if in plan mode)
527    let mode_prefix_len = if state.plan_mode { 2 } else { 0 }; // "★ " = 2 chars
528    let prompt_len = prompt.len() + 1 + mode_prefix_len; // +1 for space after prompt
529
530    // Move up to clear previous rendered lines, then to column 0
531    if state.prev_wrapped_lines > 1 {
532        execute!(
533            stdout,
534            cursor::MoveUp((state.prev_wrapped_lines - 1) as u16)
535        )?;
536    }
537    execute!(stdout, cursor::MoveToColumn(0))?;
538
539    // Clear from cursor to end of screen
540    execute!(stdout, Clear(ClearType::FromCursorDown))?;
541
542    // Print prompt and input text with mode indicator if in plan mode
543    // In raw mode, \n doesn't return to column 0, so we need \r\n
544    let display_text = state.text.replace('\n', "\r\n");
545    if state.plan_mode {
546        print!(
547            "{}★{} {}{}{} {}",
548            ansi::ORANGE,
549            ansi::RESET,
550            ansi::SUCCESS,
551            prompt,
552            ansi::RESET,
553            display_text
554        );
555    } else {
556        print!(
557            "{}{}{} {}",
558            ansi::SUCCESS,
559            prompt,
560            ansi::RESET,
561            display_text
562        );
563    }
564    stdout.flush()?;
565
566    // Calculate how many lines the text spans (counting newlines + wrapping)
567    let mut total_lines = 1;
568    let mut current_line_len = prompt_len;
569
570    for c in state.text.chars() {
571        if c == '\n' {
572            total_lines += 1;
573            current_line_len = 0;
574        } else {
575            current_line_len += 1;
576            if term_width > 0 && current_line_len > term_width {
577                total_lines += 1;
578                current_line_len = 1;
579            }
580        }
581    }
582    state.prev_wrapped_lines = total_lines;
583
584    // Render suggestions below if active
585    let mut lines_rendered = 0;
586    if state.showing_suggestions && !state.suggestions.is_empty() {
587        // Move to next line for suggestions (use \r\n in raw mode)
588        print!("\r\n");
589        lines_rendered += 1;
590
591        for (i, suggestion) in state.suggestions.iter().enumerate() {
592            let is_selected = i as i32 == state.selected;
593            let prefix = if is_selected { "▸" } else { " " };
594
595            if is_selected {
596                if suggestion.is_dir {
597                    print!(
598                        "  {}{} {}{}\r\n",
599                        ansi::CYAN,
600                        prefix,
601                        suggestion.display,
602                        ansi::RESET
603                    );
604                } else {
605                    print!(
606                        "  {}{} {}{}\r\n",
607                        ansi::WHITE,
608                        prefix,
609                        suggestion.display,
610                        ansi::RESET
611                    );
612                }
613            } else {
614                print!(
615                    "  {}{} {}{}\r\n",
616                    ansi::DIM,
617                    prefix,
618                    suggestion.display,
619                    ansi::RESET
620                );
621            }
622            lines_rendered += 1;
623        }
624
625        // Print hint
626        print!(
627            "  {}[↑↓ navigate, Enter select, Esc cancel]{}\r\n",
628            ansi::DIM,
629            ansi::RESET
630        );
631        lines_rendered += 1;
632    }
633
634    // Position cursor correctly within input (handling newlines)
635    // Calculate which line and column the cursor is on
636    let mut cursor_line = 0;
637    let mut cursor_col = prompt_len;
638
639    for (i, c) in state.text.chars().enumerate() {
640        if i >= state.cursor {
641            break;
642        }
643        if c == '\n' {
644            cursor_line += 1;
645            cursor_col = 0;
646        } else {
647            cursor_col += 1;
648            if term_width > 0 && cursor_col >= term_width {
649                cursor_line += 1;
650                cursor_col = 0;
651            }
652        }
653    }
654
655    // Move cursor from end of text to correct position
656    let lines_after_cursor = total_lines.saturating_sub(cursor_line + 1) + lines_rendered;
657    if lines_after_cursor > 0 {
658        execute!(stdout, cursor::MoveUp(lines_after_cursor as u16))?;
659    }
660    execute!(stdout, cursor::MoveToColumn(cursor_col as u16))?;
661
662    stdout.flush()?;
663    Ok(lines_rendered)
664}
665
666/// Clear rendered suggestion lines
667fn clear_suggestions(num_lines: usize, stdout: &mut io::Stdout) -> io::Result<()> {
668    if num_lines > 0 {
669        // Save position, clear lines below, restore
670        for _ in 0..num_lines {
671            execute!(stdout, cursor::MoveDown(1), Clear(ClearType::CurrentLine))?;
672        }
673        execute!(stdout, MoveUp(num_lines as u16))?;
674    }
675    Ok(())
676}
677
678/// Read user input with Claude Code-style @ file picker
679/// If `plan_mode` is true, shows the plan mode indicator below the prompt
680pub fn read_input_with_file_picker(
681    prompt: &str,
682    project_path: &std::path::Path,
683    plan_mode: bool,
684) -> InputResult {
685    let mut stdout = io::stdout();
686
687    // Enable raw mode
688    if terminal::enable_raw_mode().is_err() {
689        return read_simple_input(prompt);
690    }
691
692    // Enable bracketed paste mode to detect paste vs keypress
693    let _ = execute!(stdout, EnableBracketedPaste);
694
695    // Print prompt with mode indicator inline (no separate line)
696    if plan_mode {
697        print!(
698            "{}★{} {}{}{} ",
699            ansi::ORANGE,
700            ansi::RESET,
701            ansi::SUCCESS,
702            prompt,
703            ansi::RESET
704        );
705    } else {
706        print!("{}{}{} ", ansi::SUCCESS, prompt, ansi::RESET);
707    }
708    let _ = stdout.flush();
709
710    // Create state after printing prompt so start_row is correct
711    let mut state = InputState::new(project_path.to_path_buf(), plan_mode);
712
713    let result = loop {
714        match event::read() {
715            // Handle paste events - insert all pasted text at once
716            Ok(Event::Paste(pasted_text)) => {
717                // Normalize line endings: \r\n -> \n, lone \r -> \n
718                let normalized = pasted_text.replace("\r\n", "\n").replace('\r', "\n");
719                for c in normalized.chars() {
720                    state.insert_char(c);
721                }
722                // Render after paste completes
723                state.rendered_lines = render(&mut state, prompt, &mut stdout).unwrap_or(0);
724            }
725            Ok(Event::Key(key_event)) => {
726                match key_event.code {
727                    KeyCode::Enter => {
728                        // Shift+Enter or Alt+Enter inserts newline instead of submitting
729                        if key_event.modifiers.contains(KeyModifiers::SHIFT)
730                            || key_event.modifiers.contains(KeyModifiers::ALT)
731                        {
732                            state.insert_char('\n');
733                        } else if state.showing_suggestions && state.selected >= 0 {
734                            // Accept selection, don't submit
735                            state.accept_selection();
736                        } else if !state.text.trim().is_empty() {
737                            // Submit
738                            print!("\r\n");
739                            let _ = stdout.flush();
740                            break InputResult::Submit(state.text.clone());
741                        }
742                    }
743                    KeyCode::Tab => {
744                        // Tab also accepts selection
745                        if state.showing_suggestions && state.selected >= 0 {
746                            state.accept_selection();
747                        }
748                    }
749                    KeyCode::BackTab => {
750                        // Shift+Tab toggles planning mode
751                        print!("\r\n");
752                        let _ = stdout.flush();
753                        break InputResult::TogglePlanMode;
754                    }
755                    KeyCode::Esc => {
756                        if state.showing_suggestions {
757                            state.close_suggestions();
758                        } else {
759                            print!("\r\n");
760                            let _ = stdout.flush();
761                            break InputResult::Cancel;
762                        }
763                    }
764                    KeyCode::Char('c') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
765                        // Ctrl+C always exits (consistent with standard CLI behavior)
766                        print!("\r\n");
767                        let _ = stdout.flush();
768                        break InputResult::Cancel;
769                    }
770                    KeyCode::Char('d') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
771                        print!("\r\n");
772                        let _ = stdout.flush();
773                        break InputResult::Exit;
774                    }
775                    KeyCode::Up => {
776                        if state.showing_suggestions {
777                            state.select_up();
778                        } else {
779                            state.cursor_up();
780                        }
781                    }
782                    KeyCode::Down => {
783                        if state.showing_suggestions {
784                            state.select_down();
785                        } else {
786                            state.cursor_down();
787                        }
788                    }
789                    KeyCode::Left => {
790                        state.cursor_left();
791                        // Close suggestions if cursor moves before @
792                        if let Some(start) = state.completion_start
793                            && state.cursor <= start
794                        {
795                            state.close_suggestions();
796                        }
797                    }
798                    KeyCode::Right => {
799                        state.cursor_right();
800                    }
801                    KeyCode::Home | KeyCode::Char('a')
802                        if key_event.modifiers.contains(KeyModifiers::CONTROL) =>
803                    {
804                        state.cursor_home();
805                        state.close_suggestions();
806                    }
807                    KeyCode::End | KeyCode::Char('e')
808                        if key_event.modifiers.contains(KeyModifiers::CONTROL) =>
809                    {
810                        state.cursor_end();
811                    }
812                    // Ctrl+U - Clear entire input
813                    KeyCode::Char('u') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
814                        state.clear_all();
815                    }
816                    // Ctrl+K - Delete to beginning of current line (works on all platforms)
817                    KeyCode::Char('k') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
818                        state.delete_to_line_start();
819                    }
820                    // Ctrl+Shift+Backspace - Delete to beginning of current line (cross-platform)
821                    KeyCode::Backspace
822                        if key_event.modifiers.contains(KeyModifiers::CONTROL)
823                            && key_event.modifiers.contains(KeyModifiers::SHIFT) =>
824                    {
825                        state.delete_to_line_start();
826                    }
827                    // Cmd+Backspace (Mac) - Delete to beginning of current line (if terminal passes it)
828                    KeyCode::Backspace if key_event.modifiers.contains(KeyModifiers::SUPER) => {
829                        state.delete_to_line_start();
830                    }
831                    // Ctrl+W or Alt+Backspace - Delete word left
832                    KeyCode::Char('w') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
833                        state.delete_word_left();
834                    }
835                    KeyCode::Backspace if key_event.modifiers.contains(KeyModifiers::ALT) => {
836                        state.delete_word_left();
837                    }
838                    KeyCode::Backspace if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
839                        state.delete_word_left();
840                    }
841                    // Ctrl+J - Insert newline (multi-line input)
842                    KeyCode::Char('j') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
843                        state.insert_char('\n');
844                    }
845                    KeyCode::Backspace => {
846                        state.backspace();
847                    }
848                    KeyCode::Char('\n') => {
849                        // Handle newline char that might come through during paste
850                        state.insert_char('\n');
851                    }
852                    KeyCode::Char(c) => {
853                        state.insert_char(c);
854                    }
855                    _ => {}
856                }
857
858                // Only render if no more events are pending (batches rapid input like paste)
859                // This prevents thousands of renders during paste operations
860                let should_render =
861                    !event::poll(std::time::Duration::from_millis(0)).unwrap_or(false);
862                if should_render {
863                    state.rendered_lines = render(&mut state, prompt, &mut stdout).unwrap_or(0);
864                }
865            }
866            Ok(Event::Resize(_, _)) => {
867                // Redraw on resize
868                state.rendered_lines = render(&mut state, prompt, &mut stdout).unwrap_or(0);
869            }
870            Err(_) => {
871                break InputResult::Cancel;
872            }
873            _ => {}
874        }
875    };
876
877    // Disable bracketed paste mode
878    let _ = execute!(stdout, DisableBracketedPaste);
879
880    // Disable raw mode
881    let _ = terminal::disable_raw_mode();
882
883    // Clean up any remaining rendered lines
884    if state.rendered_lines > 0 {
885        let _ = clear_suggestions(state.rendered_lines, &mut stdout);
886    }
887
888    result
889}
890
891/// Simple fallback input without raw mode
892fn read_simple_input(prompt: &str) -> InputResult {
893    print!("{}{}{} ", ansi::SUCCESS, prompt, ansi::RESET);
894    let _ = io::stdout().flush();
895
896    let mut input = String::new();
897    match io::stdin().read_line(&mut input) {
898        Ok(_) => {
899            let trimmed = input.trim();
900            if trimmed.eq_ignore_ascii_case("exit") || trimmed == "/exit" || trimmed == "/quit" {
901                InputResult::Exit
902            } else {
903                InputResult::Submit(trimmed.to_string())
904            }
905        }
906        Err(_) => InputResult::Cancel,
907    }
908}