Skip to main content

limit_cli/tui/app/
app_impl.rs

1//! TUI Application module
2//!
3//! Contains the main TUI application loop (`TuiApp`)
4
5use crate::clipboard::ClipboardManager;
6use crate::error::CliError;
7use crate::tui::autocomplete::FileAutocompleteManager;
8use crate::tui::bridge::TuiBridge;
9use crate::tui::input::{InputEditor, InputHandler};
10use crate::tui::ui::UiRenderer;
11use crate::tui::TuiState;
12use crossterm::event::{
13    self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
14    Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind,
15};
16use crossterm::execute;
17use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
18use limit_tui::components::Message;
19use ratatui::{backend::CrosstermBackend, Terminal};
20use std::io;
21use std::sync::{Arc, Mutex};
22use std::time::{Duration, Instant};
23
24pub struct TuiApp {
25    tui_bridge: TuiBridge,
26    terminal: Terminal<CrosstermBackend<io::Stdout>>,
27    running: bool,
28    /// Input text editor
29    input_editor: InputEditor,
30    /// History file path
31    history_path: std::path::PathBuf,
32    status_message: String,
33    status_is_error: bool,
34    /// Mouse selection state
35    mouse_selection_start: Option<(u16, u16)>,
36    /// Clipboard manager (shared with CommandContext)
37    clipboard: Option<Arc<Mutex<ClipboardManager>>>,
38    /// File autocomplete manager
39    autocomplete_manager: FileAutocompleteManager,
40    /// Cancellation token for current LLM operation
41    cancellation_token: Option<tokio_util::sync::CancellationToken>,
42    /// Input handler for keyboard/mouse events
43    input_handler: InputHandler,
44    /// Command registry for handling /commands
45    command_registry: crate::tui::commands::CommandRegistry,
46}
47
48impl TuiApp {
49    /// Create a new TUI application
50    pub fn new(tui_bridge: TuiBridge) -> Result<Self, CliError> {
51        let backend = CrosstermBackend::new(io::stdout());
52        let terminal =
53            Terminal::new(backend).map_err(|e| CliError::IoError(io::Error::other(e)))?;
54
55        let session_id = tui_bridge.session_id();
56        tracing::info!("TUI started with session: {}", session_id);
57
58        // Initialize history path (~/.limit/input_history.bin)
59        let home_dir = dirs::home_dir()
60            .ok_or_else(|| CliError::ConfigError("Failed to get home directory".to_string()))?;
61        let limit_dir = home_dir.join(".limit");
62        let history_path = limit_dir.join("input_history.bin");
63
64        // Load input editor with history
65        let input_editor = InputEditor::with_history(&history_path)
66            .map_err(|e| CliError::ConfigError(format!("Failed to load input history: {}", e)))?;
67
68        let clipboard = match ClipboardManager::new() {
69            Ok(cb) => {
70                tracing::debug!("✓ Clipboard initialized successfully");
71                tracing::info!("Clipboard initialized successfully");
72                Some(Arc::new(Mutex::new(cb)))
73            }
74            Err(e) => {
75                tracing::debug!("✗ Clipboard initialization failed: {}", e);
76                tracing::warn!("Clipboard unavailable: {}", e);
77                None
78            }
79        };
80
81        // Initialize autocomplete manager with current directory
82        let working_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
83        let autocomplete_manager = FileAutocompleteManager::new(working_dir);
84
85        // Initialize command registry
86        let command_registry = crate::tui::commands::create_default_registry();
87
88        Ok(Self {
89            tui_bridge,
90            terminal,
91            running: true,
92            input_editor,
93            history_path,
94            status_message: "Ready - Type a message and press Enter".to_string(),
95            status_is_error: false,
96            mouse_selection_start: None,
97            clipboard,
98            autocomplete_manager,
99            cancellation_token: None,
100            input_handler: InputHandler::new(),
101            command_registry,
102        })
103    }
104
105    /// Run the TUI event loop
106    pub fn run(&mut self) -> Result<(), CliError> {
107        // Enter alternate screen - creates a clean buffer for TUI
108        execute!(std::io::stdout(), EnterAlternateScreen)
109            .map_err(|e| CliError::IoError(io::Error::other(e)))?;
110
111        // Enable mouse capture for scroll support
112        execute!(std::io::stdout(), EnableMouseCapture)
113            .map_err(|e| CliError::IoError(io::Error::other(e)))?;
114
115        // Enable bracketed paste for multi-line paste support
116        execute!(std::io::stdout(), EnableBracketedPaste)
117            .map_err(|e| CliError::IoError(io::Error::other(e)))?;
118
119        crossterm::terminal::enable_raw_mode()
120            .map_err(|e| CliError::IoError(io::Error::other(e)))?;
121
122        // Guard to ensure cleanup on panic
123        struct AlternateScreenGuard;
124        impl Drop for AlternateScreenGuard {
125            fn drop(&mut self) {
126                let _ = crossterm::terminal::disable_raw_mode();
127                let _ = execute!(std::io::stdout(), DisableBracketedPaste);
128                let _ = execute!(std::io::stdout(), DisableMouseCapture);
129                let _ = execute!(std::io::stdout(), LeaveAlternateScreen);
130            }
131        }
132        let _guard = AlternateScreenGuard;
133
134        self.run_inner()
135    }
136
137    fn run_inner(&mut self) -> Result<(), CliError> {
138        while self.running {
139            // Process events from the agent
140            self.tui_bridge.process_events()?;
141
142            // Update spinner if in thinking state
143            if matches!(self.tui_bridge.state(), TuiState::Thinking) {
144                self.tui_bridge.tick_spinner();
145            }
146
147            // Update status based on state
148            self.update_status();
149
150            // Handle user input with poll timeout
151            if event::poll(Duration::from_millis(100))
152                .map_err(|e| CliError::IoError(io::Error::other(e)))?
153            {
154                match event::read().map_err(|e| CliError::IoError(io::Error::other(e)))? {
155                    Event::Key(key) => {
156                        // Log ALL key events (not just Press) to debug modifier detection
157                        tracing::debug!(
158                            "Event::Key - code={:?} mod={:?} kind={:?}",
159                            key.code,
160                            key.modifiers,
161                            key.kind
162                        );
163
164                        if key.kind == KeyEventKind::Press {
165                            self.handle_key_event(key)?;
166                        }
167                    }
168                    Event::Mouse(mouse) => {
169                        match mouse.kind {
170                            MouseEventKind::Down(MouseButton::Left) => {
171                                tracing::debug!("MouseDown at ({}, {})", mouse.column, mouse.row);
172                                self.mouse_selection_start = Some((mouse.column, mouse.row));
173                                // Map screen position to message/offset and start selection
174                                let chat = self.tui_bridge.chat_view().lock().unwrap();
175                                tracing::debug!(
176                                    "  render_positions count: {}",
177                                    chat.render_position_count()
178                                );
179                                if let Some((msg_idx, char_offset)) =
180                                    chat.screen_to_text_pos(mouse.column, mouse.row)
181                                {
182                                    tracing::debug!(
183                                        "  -> Starting selection at msg={}, offset={}",
184                                        msg_idx,
185                                        char_offset
186                                    );
187                                    drop(chat);
188                                    self.tui_bridge
189                                        .chat_view()
190                                        .lock()
191                                        .unwrap()
192                                        .start_selection(msg_idx, char_offset);
193                                } else {
194                                    tracing::debug!("  -> No match, clearing selection");
195                                    drop(chat);
196                                    self.tui_bridge
197                                        .chat_view()
198                                        .lock()
199                                        .unwrap()
200                                        .clear_selection();
201                                }
202                            }
203                            MouseEventKind::Drag(MouseButton::Left) => {
204                                if self.mouse_selection_start.is_some() {
205                                    // Extend selection to current position
206                                    let chat = self.tui_bridge.chat_view().lock().unwrap();
207                                    if let Some((msg_idx, char_offset)) =
208                                        chat.screen_to_text_pos(mouse.column, mouse.row)
209                                    {
210                                        drop(chat);
211                                        self.tui_bridge
212                                            .chat_view()
213                                            .lock()
214                                            .unwrap()
215                                            .extend_selection(msg_idx, char_offset);
216                                    }
217                                }
218                            }
219                            MouseEventKind::Up(MouseButton::Left) => {
220                                self.mouse_selection_start = None;
221                            }
222                            MouseEventKind::ScrollUp => {
223                                let mut chat = self.tui_bridge.chat_view().lock().unwrap();
224                                chat.scroll_up();
225                            }
226                            MouseEventKind::ScrollDown => {
227                                let mut chat = self.tui_bridge.chat_view().lock().unwrap();
228                                chat.scroll_down();
229                            }
230                            _ => {}
231                        }
232                    }
233                    Event::Paste(pasted) => {
234                        if !self.tui_bridge.is_busy() {
235                            self.insert_paste(&pasted);
236                        }
237                    }
238                    _ => {}
239                }
240            } else {
241                // No key event - tick cursor blink
242                self.tick_cursor_blink();
243            }
244
245            // Draw the TUI
246            self.draw()?;
247        }
248
249        // Save session before exiting
250        if let Err(e) = self.tui_bridge.save_session() {
251            tracing::error!("Failed to save session: {}", e);
252        }
253
254        // Save input history before exiting
255        if let Err(e) = self.input_editor.save_history(&self.history_path) {
256            tracing::error!("Failed to save input history: {}", e);
257        }
258
259        Ok(())
260    }
261
262    fn update_status(&mut self) {
263        let session_id = self.tui_bridge.session_id();
264        let has_activity = self
265            .tui_bridge
266            .activity_feed()
267            .lock()
268            .unwrap()
269            .has_in_progress();
270
271        match self.tui_bridge.state() {
272            TuiState::Idle => {
273                if has_activity {
274                    // Show spinner when there are in-progress activities
275                    let spinner = self.tui_bridge.spinner().lock().unwrap();
276                    self.status_message = format!("{} Processing...", spinner.current_frame());
277                } else {
278                    self.status_message = format!(
279                        "Ready | Session: {}",
280                        session_id.chars().take(8).collect::<String>()
281                    );
282                }
283                self.status_is_error = false;
284            }
285            TuiState::Thinking => {
286                let spinner = self.tui_bridge.spinner().lock().unwrap();
287                self.status_message = format!("{} Thinking...", spinner.current_frame());
288                self.status_is_error = false;
289            }
290        }
291    }
292
293    /// Insert pasted text at cursor position without submitting
294    fn insert_paste(&mut self, text: &str) {
295        let truncated = self.input_editor.insert_paste(text);
296        if truncated {
297            self.status_message = "Paste truncated (too large)".to_string();
298            self.status_is_error = true;
299        }
300    }
301
302    /// Check if the current key event is a copy/paste shortcut
303    /// Returns true for Ctrl+C/V on Linux/Windows, Cmd+C/V on macOS
304    /// Note: Some macOS terminals report Cmd as CONTROL instead of SUPER
305    fn is_copy_paste_modifier(&self, key: &KeyEvent, char: char) -> bool {
306        #[cfg(target_os = "macos")]
307        {
308            // Accept both SUPER and CONTROL on macOS since terminal emulators vary
309            let has_super = key.modifiers.contains(KeyModifiers::SUPER);
310            let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
311            let result = key.code == KeyCode::Char(char) && (has_super || has_ctrl);
312            tracing::debug!("is_copy_paste_modifier('{}') macOS: code={:?}, mod={:?}, super={}, ctrl={}, result={}", 
313                char, key.code, key.modifiers, has_super, has_ctrl, result);
314            result
315        }
316        #[cfg(not(target_os = "macos"))]
317        {
318            let result =
319                key.code == KeyCode::Char(char) && key.modifiers.contains(KeyModifiers::CONTROL);
320            tracing::debug!(
321                "is_copy_paste_modifier('{}') non-macOS: code={:?}, mod={:?}, ctrl={:?}, result={}",
322                char,
323                key.code,
324                key.modifiers,
325                KeyModifiers::CONTROL,
326                result
327            );
328            result
329        }
330    }
331
332    fn tick_cursor_blink(&mut self) {
333        // Delegate to InputHandler
334        self.input_handler.tick_cursor_blink();
335    }
336
337    fn handle_key_event(&mut self, key: KeyEvent) -> Result<(), CliError> {
338        // Direct file logging (always works)
339        tracing::debug!(
340            "handle_key_event: code={:?} mod={:?} kind={:?}",
341            key.code,
342            key.modifiers,
343            key.kind
344        );
345
346        // Special log for 'c' and 'v' keys to debug copy/paste
347        if matches!(key.code, KeyCode::Char('c') | KeyCode::Char('v')) {
348            tracing::debug!(
349                ">>> SPECIAL: '{}' key detected with modifiers: {:?} (SUPER={:?}, CONTROL={:?})",
350                if matches!(key.code, KeyCode::Char('c')) {
351                    'c'
352                } else {
353                    'v'
354                },
355                key.modifiers,
356                key.modifiers.contains(KeyModifiers::SUPER),
357                key.modifiers.contains(KeyModifiers::CONTROL)
358            );
359        }
360
361        // Copy selection to clipboard (Ctrl/Cmd+C)
362        if self.is_copy_paste_modifier(&key, 'c') {
363            tracing::debug!("✓ Copy shortcut CONFIRMED - processing...");
364            let mut chat = self.tui_bridge.chat_view().lock().unwrap();
365            let has_selection = chat.has_selection();
366            tracing::debug!("has_selection={}", has_selection);
367
368            if has_selection {
369                if let Some(selected) = chat.get_selected_text() {
370                    tracing::debug!("Selected text length={}", selected.len());
371                    if !selected.is_empty() {
372                        if let Some(ref clipboard) = self.clipboard {
373                            tracing::debug!("Attempting to copy to clipboard...");
374                            match clipboard.lock().unwrap().set_text(&selected) {
375                                Ok(()) => {
376                                    tracing::debug!("✓ Clipboard copy successful");
377                                    self.status_message = "Copied to clipboard".to_string();
378                                    self.status_is_error = false;
379                                }
380                                Err(e) => {
381                                    tracing::debug!("✗ Clipboard copy failed: {}", e);
382                                    self.status_message = format!("Clipboard error: {}", e);
383                                    self.status_is_error = true;
384                                }
385                            }
386                        } else {
387                            tracing::debug!("✗ Clipboard not available (None)");
388                            self.status_message = "Clipboard not available".to_string();
389                            self.status_is_error = true;
390                        }
391                    } else {
392                        tracing::debug!("Selected text is empty");
393                    }
394                    chat.clear_selection();
395                } else {
396                    tracing::debug!("get_selected_text() returned None");
397                }
398                return Ok(());
399            }
400
401            // No selection - do nothing (Ctrl+C is only for copying text)
402            tracing::debug!("Ctrl/Cmd+C with no selection - ignoring");
403            return Ok(());
404        }
405
406        // Paste from clipboard (Ctrl/Cmd+V)
407        if self.is_copy_paste_modifier(&key, 'v') && !self.tui_bridge.is_busy() {
408            tracing::debug!("✓ Paste shortcut CONFIRMED - processing...");
409            let clipboard_result = if let Some(ref clipboard) = self.clipboard {
410                tracing::debug!("Attempting to read from clipboard...");
411                Some(clipboard.lock().unwrap().get_text())
412            } else {
413                None
414            };
415
416            match clipboard_result {
417                Some(Ok(text)) if !text.is_empty() => {
418                    tracing::debug!("Read {} chars from clipboard", text.len());
419                    self.insert_paste(&text);
420                }
421                Some(Ok(_)) => {
422                    tracing::debug!("Clipboard is empty");
423                }
424                Some(Err(e)) => {
425                    tracing::debug!("✗ Failed to read clipboard: {}", e);
426                    self.status_message = format!("Could not read clipboard: {}", e);
427                    self.status_is_error = true;
428                }
429                None => {
430                    tracing::debug!("✗ Clipboard not available (None)");
431                    self.status_message = "Clipboard not available".to_string();
432                    self.status_is_error = true;
433                }
434            }
435            return Ok(());
436        }
437
438        // Handle autocomplete navigation FIRST (before general scrolling)
439        let autocomplete_active = self.autocomplete_manager.is_active();
440        tracing::debug!(
441            "Key handling: autocomplete_active={}, is_busy={}, history_len={}",
442            autocomplete_active,
443            self.tui_bridge.is_busy(),
444            self.input_editor.history().len()
445        );
446
447        if autocomplete_active {
448            match key.code {
449                KeyCode::Up => {
450                    self.autocomplete_manager.navigate_up();
451                    return Ok(());
452                }
453                KeyCode::Down => {
454                    self.autocomplete_manager.navigate_down();
455                    return Ok(());
456                }
457                KeyCode::Enter | KeyCode::Tab => {
458                    self.accept_file_completion();
459                    return Ok(());
460                }
461                KeyCode::Esc => {
462                    self.autocomplete_manager.deactivate();
463                    return Ok(());
464                }
465                _ => {}
466            }
467        }
468
469        // Handle ESC for cancellation (must be before is_busy check)
470        if key.code == KeyCode::Esc {
471            // If autocomplete is active, cancel it
472            if self.autocomplete_manager.is_active() {
473                self.autocomplete_manager.deactivate();
474            } else if self.tui_bridge.is_busy() {
475                // Double-ESC to cancel current operation
476                let now = Instant::now();
477                let last_esc_time = self.input_handler.last_esc_time();
478                let should_cancel = if let Some(last_esc) = last_esc_time {
479                    now.duration_since(last_esc) < Duration::from_millis(1000)
480                } else {
481                    false
482                };
483
484                if should_cancel {
485                    self.cancel_current_operation();
486                } else {
487                    // First ESC - show feedback
488                    self.status_message = "Press ESC again to cancel".to_string();
489                    self.status_is_error = false;
490                    self.input_handler.set_last_esc_time(now);
491                }
492            } else {
493                tracing::debug!("Esc pressed, exiting");
494                self.running = false;
495            }
496            return Ok(());
497        }
498
499        // Allow scrolling even when agent is busy (PageUp/PageDown only)
500        // Calculate actual viewport height dynamically
501        let term_height = self.terminal.size().map(|s| s.height).unwrap_or(24);
502        let viewport_height = term_height
503            .saturating_sub(1) // status bar - input area (6 lines) - borders (~2)
504            .saturating_sub(7); // status (1) + input (6) + top/bottom borders (2) = 9
505        match key.code {
506            KeyCode::PageUp => {
507                let mut chat = self.tui_bridge.chat_view().lock().unwrap();
508                chat.scroll_page_up(viewport_height);
509                return Ok(());
510            }
511            KeyCode::PageDown => {
512                let mut chat = self.tui_bridge.chat_view().lock().unwrap();
513                chat.scroll_page_down(viewport_height);
514                return Ok(());
515            }
516            KeyCode::Up => {
517                // Use for history navigation
518                tracing::debug!(
519                    "Up arrow: navigating history up, history_len={}",
520                    self.input_editor.history().len()
521                );
522                let navigated = self.input_editor.navigate_history_up();
523                tracing::debug!(
524                    "Up arrow: navigated={}, text='{}'",
525                    navigated,
526                    self.input_editor.text()
527                );
528                return Ok(());
529            }
530            KeyCode::Down => {
531                // Use for history navigation
532                tracing::debug!(
533                    "Down arrow: navigating history down, is_navigating={}",
534                    self.input_editor.is_navigating_history()
535                );
536                let navigated = self.input_editor.navigate_history_down();
537                tracing::debug!(
538                    "Down arrow: navigated={}, text='{}'",
539                    navigated,
540                    self.input_editor.text()
541                );
542                return Ok(());
543            }
544            _ => {}
545        }
546
547        // Don't accept input while agent is busy
548        if self.tui_bridge.is_busy() {
549            tracing::debug!("Agent busy, ignoring");
550            return Ok(());
551        }
552
553        // Handle backspace - try multiple detection methods
554        if self.handle_backspace(&key) {
555            tracing::debug!("Backspace handled, input: {:?}", self.input_editor.text());
556            return Ok(());
557        }
558
559        match key.code {
560            KeyCode::Delete => {
561                if self.input_editor.delete_char_at() {
562                    tracing::debug!("Delete: input now: {:?}", self.input_editor.text());
563                }
564            }
565            KeyCode::Left => {
566                tracing::debug!(
567                    "KeyCode::Left: has_pasted_content={}",
568                    self.input_editor.has_pasted_content()
569                );
570                self.input_editor.move_left();
571            }
572            KeyCode::Right => {
573                tracing::debug!(
574                    "KeyCode::Right: has_pasted_content={}",
575                    self.input_editor.has_pasted_content()
576                );
577                self.input_editor.move_right();
578            }
579            KeyCode::Home => {
580                self.input_editor.move_to_start();
581            }
582            KeyCode::End => {
583                self.input_editor.move_to_end();
584            }
585            KeyCode::Enter => {
586                // If autocomplete is active, it's already handled above
587                self.handle_enter()?;
588            }
589            // Regular character input (including UTF-8)
590            KeyCode::Char(c)
591                if key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT =>
592            {
593                // Check if we're triggering autocomplete with @
594                if c == '@' {
595                    // Insert @ character
596                    self.input_editor.insert_char('@');
597
598                    // Activate autocomplete
599                    self.activate_file_autocomplete();
600                } else if self.autocomplete_manager.is_active() {
601                    // Update autocomplete and insert character
602                    self.autocomplete_manager.append_char(c);
603                    self.input_editor.insert_char(c);
604                } else {
605                    // Normal character insertion
606                    self.input_editor.insert_char(c);
607                }
608            }
609            _ => {
610                // Ignore other keys
611            }
612        }
613
614        Ok(())
615    }
616
617    /// Handle backspace with multiple detection methods
618    fn handle_backspace(&mut self, key: &KeyEvent) -> bool {
619        // Method 1: Standard Backspace keycode
620        if key.code == KeyCode::Backspace {
621            tracing::debug!("Backspace detected via KeyCode::Backspace");
622            self.delete_char_before_cursor();
623            return true;
624        }
625
626        // Method 2: Ctrl+H (common backspace mapping)
627        if key.code == KeyCode::Char('h') && key.modifiers == KeyModifiers::CONTROL {
628            tracing::debug!("Backspace detected via Ctrl+H");
629            self.delete_char_before_cursor();
630            return true;
631        }
632
633        // Method 3: Check for DEL (127) or BS (8) characters
634        if let KeyCode::Char(c) = key.code {
635            if c == '\x7f' || c == '\x08' {
636                tracing::debug!("Backspace detected via char code: {}", c as u8);
637                self.delete_char_before_cursor();
638                return true;
639            }
640        }
641
642        false
643    }
644
645    fn delete_char_before_cursor(&mut self) {
646        tracing::debug!(
647            "delete_char: cursor={}, len={}, input={:?}",
648            self.input_editor.cursor(),
649            self.input_editor.text().len(),
650            self.input_editor.text()
651        );
652
653        // If autocomplete is active, handle backspace specially
654        if self.autocomplete_manager.is_active() {
655            let should_close = self.autocomplete_manager.backspace();
656
657            if should_close
658                && self.input_editor.cursor() > 0
659                && self.input_editor.char_before_cursor() == Some('@')
660            {
661                self.input_editor.delete_char_before();
662                self.autocomplete_manager.deactivate();
663                return;
664            }
665        }
666
667        // Normal backspace
668        self.input_editor.delete_char_before();
669    }
670
671    /// Activate file autocomplete
672    fn activate_file_autocomplete(&mut self) {
673        let trigger_pos = self.input_editor.cursor() - 1; // Position of @
674        self.autocomplete_manager.activate(trigger_pos);
675
676        tracing::info!(
677            "🔍 ACTIVATED AUTOCOMPLETE: trigger_pos={}, cursor={}, text='{}'",
678            trigger_pos,
679            self.input_editor.cursor(),
680            self.input_editor.text()
681        );
682    }
683
684    /// Accept selected file completion
685    fn accept_file_completion(&mut self) {
686        // Get the selected match WITHOUT using accept_completion()
687        // We do this manually to avoid the trailing space issue
688        let selected = self.autocomplete_manager.selected_match().cloned();
689
690        if let Some(selected) = selected {
691            let trigger_pos = self.autocomplete_manager.trigger_pos().unwrap_or(0);
692            let current_cursor = self.input_editor.cursor();
693
694            tracing::info!(
695                "🎯 ACCEPT: trigger_pos={}, cursor={}, path='{}'",
696                trigger_pos,
697                current_cursor,
698                selected.path
699            );
700
701            // Calculate what to remove: from @+1 to current cursor
702            let remove_start = trigger_pos + 1;
703            let remove_end = current_cursor;
704
705            tracing::info!(
706                "🎯 REMOVE RANGE: {}..{} = '{}'",
707                remove_start,
708                remove_end,
709                &self.input_editor.text()
710                    [remove_start..remove_end.min(self.input_editor.text().len())]
711            );
712
713            // Use replace_range to atomically replace the query with the completion
714            // Use selected.path WITHOUT trailing space
715            if remove_end > remove_start {
716                self.input_editor
717                    .replace_range(remove_start, remove_end, &selected.path);
718            } else {
719                // Nothing to remove, just insert after @
720                self.input_editor.set_cursor(remove_start);
721                self.input_editor.insert_str(&selected.path);
722            }
723
724            // Add trailing space AFTER the completion
725            self.input_editor.insert_char(' ');
726
727            tracing::info!(
728                "🎯 FINAL: '{}', cursor={}",
729                self.input_editor.text(),
730                self.input_editor.cursor()
731            );
732        }
733
734        // Close autocomplete
735        self.autocomplete_manager.deactivate();
736    }
737
738    fn handle_enter(&mut self) -> Result<(), CliError> {
739        let text = self.input_editor.take_and_add_to_history();
740
741        if text.is_empty() {
742            return Ok(());
743        }
744
745        tracing::info!("Enter pressed with text: {:?}", text);
746
747        // Check if it's a command (starts with /)
748        if text.starts_with('/') {
749            use crate::tui::commands::{CommandContext, CommandResult};
750
751            let mut cmd_ctx = CommandContext::new(
752                self.tui_bridge.chat_view().clone(),
753                self.tui_bridge.session_manager(),
754                self.tui_bridge.session_id(),
755                self.tui_bridge.state_arc(),
756                self.tui_bridge.messages(),
757                self.tui_bridge.total_input_tokens_arc(),
758                self.tui_bridge.total_output_tokens_arc(),
759                self.clipboard.clone(),
760            );
761
762            // Execute command via registry
763            match self.command_registry.parse_and_execute(&text, &mut cmd_ctx) {
764                Ok(Some(result)) => {
765                    // Update session_id if changed by command
766                    self.tui_bridge
767                        .session_id_arc()
768                        .lock()
769                        .unwrap()
770                        .clone_from(&cmd_ctx.session_id);
771
772                    match result {
773                        CommandResult::Exit => {
774                            self.running = false;
775                            return Ok(());
776                        }
777                        CommandResult::ClearChat => {
778                            // Already handled by command
779                            return Ok(());
780                        }
781                        CommandResult::NewSession | CommandResult::LoadSession(_) => {
782                            // Session was changed, sync state
783                            *self.tui_bridge.messages().lock().unwrap() =
784                                cmd_ctx.messages.lock().unwrap().clone();
785                            *self.tui_bridge.total_input_tokens_arc().lock().unwrap() =
786                                *cmd_ctx.total_input_tokens.lock().unwrap();
787                            *self.tui_bridge.total_output_tokens_arc().lock().unwrap() =
788                                *cmd_ctx.total_output_tokens.lock().unwrap();
789                            return Ok(());
790                        }
791                        CommandResult::Continue
792                        | CommandResult::Message(_)
793                        | CommandResult::Share(_) => {
794                            return Ok(());
795                        }
796                    }
797                }
798                Ok(None) => {
799                    // Not a command, fall through to LLM processing
800                }
801                Err(e) => {
802                    tracing::error!("Command error: {}", e);
803                    // Show error to user instead of crashing
804                    self.tui_bridge
805                        .chat_view()
806                        .lock()
807                        .unwrap()
808                        .add_message(Message::system(format!("Error: {}", e)));
809                    return Ok(());
810                }
811            }
812        }
813
814        // Handle bare commands (without /) for backwards compatibility
815        let text_lower = text.to_lowercase();
816        if text_lower == "exit" || text_lower == "quit" {
817            tracing::info!("Exit command detected, exiting");
818            self.running = false;
819            return Ok(());
820        }
821
822        if text_lower == "clear" {
823            tracing::info!("Clear command detected");
824            self.tui_bridge.chat_view().lock().unwrap().clear();
825            return Ok(());
826        }
827
828        if text_lower == "help" {
829            tracing::info!("Help command detected");
830            let help_msg = Message::system(
831                "Available commands:\n\
832                 /help  - Show this help message\n\
833                 /clear - Clear chat history\n\
834                 /exit  - Exit the application\n\
835                 /quit  - Exit the application\n\
836                 /session list  - List all sessions\n\
837                 /session new   - Create a new session\n\
838                 /session load  <id> - Load a session by ID\n\
839                 /share         - Copy session to clipboard (markdown)\n\
840                 /share md      - Export session as markdown file\n\
841                 /share json    - Export session as JSON file\n\
842                 \n\
843                 Page Up/Down - Scroll chat history\n\
844                 Up/Down (empty input) - Navigate input history"
845                    .to_string(),
846            );
847            self.tui_bridge
848                .chat_view()
849                .lock()
850                .unwrap()
851                .add_message(help_msg);
852            return Ok(());
853        }
854
855        // Add user message to chat immediately for visual feedback
856        self.tui_bridge.add_user_message(text.clone());
857
858        // Get new operation ID and ensure state is Idle
859        let operation_id = self.tui_bridge.next_operation_id();
860        tracing::debug!("handle_enter: new operation_id={}", operation_id);
861        self.tui_bridge.set_state(TuiState::Idle);
862
863        // Create cancellation token for this operation
864        let cancel_token = tokio_util::sync::CancellationToken::new();
865        self.cancellation_token = Some(cancel_token.clone());
866
867        // Clone Arcs for the spawned thread
868        let messages = self.tui_bridge.messages();
869        let agent_bridge = self.tui_bridge.agent_bridge_arc();
870        let session_manager = self.tui_bridge.session_manager();
871        let session_id = self.tui_bridge.session_id();
872        let total_input_tokens = self.tui_bridge.total_input_tokens_arc();
873        let total_output_tokens = self.tui_bridge.total_output_tokens_arc();
874
875        tracing::debug!("Spawning LLM processing thread");
876
877        // Spawn a thread to process the message without blocking the UI
878        std::thread::spawn(move || {
879            // Create a new tokio runtime for this thread
880            let rt = tokio::runtime::Runtime::new().unwrap();
881
882            // Safe: we're in a dedicated thread, this won't cause issues
883            #[allow(clippy::await_holding_lock)]
884            rt.block_on(async {
885                // Check for cancellation BEFORE acquiring locks
886                if cancel_token.is_cancelled() {
887                    tracing::debug!("Operation cancelled before acquiring locks");
888                    return;
889                }
890
891                // Try to acquire locks with timeout to avoid blocking indefinitely
892                let messages_guard = {
893                    let mut attempts = 0;
894                    loop {
895                        if cancel_token.is_cancelled() {
896                            tracing::debug!("Operation cancelled while waiting for messages lock");
897                            return;
898                        }
899                        match messages.try_lock() {
900                            Ok(guard) => break guard,
901                            Err(std::sync::TryLockError::WouldBlock) => {
902                                attempts += 1;
903                                if attempts > 50 {
904                                    tracing::error!("Timeout waiting for messages lock");
905                                    return;
906                                }
907                                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
908                            }
909                            Err(e) => {
910                                tracing::error!("Failed to lock messages: {}", e);
911                                return;
912                            }
913                        }
914                    }
915                };
916
917                let mut messages_guard = messages_guard;
918
919                // Check cancellation again before acquiring bridge lock
920                if cancel_token.is_cancelled() {
921                    tracing::debug!("Operation cancelled before acquiring bridge lock");
922                    return;
923                }
924
925                let bridge_guard = {
926                    let mut attempts = 0;
927                    loop {
928                        if cancel_token.is_cancelled() {
929                            tracing::debug!("Operation cancelled while waiting for bridge lock");
930                            return;
931                        }
932                        match agent_bridge.try_lock() {
933                            Ok(guard) => break guard,
934                            Err(std::sync::TryLockError::WouldBlock) => {
935                                attempts += 1;
936                                if attempts > 50 {
937                                    tracing::error!("Timeout waiting for bridge lock");
938                                    return;
939                                }
940                                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
941                            }
942                            Err(e) => {
943                                tracing::error!("Failed to lock agent_bridge: {}", e);
944                                return;
945                            }
946                        }
947                    }
948                };
949
950                let mut bridge = bridge_guard;
951
952                // Set cancellation token and operation ID
953                bridge.set_cancellation_token(cancel_token.clone(), operation_id);
954
955                match bridge.process_message(&text, &mut messages_guard).await {
956                    Ok(_response) => {
957                        // Don't sync ChatView here - it causes race conditions with ContentChunk events
958                        // ChatView is already updated via ContentChunk events during streaming
959                        // The messages_guard is the authoritative source for session persistence
960
961                        // Auto-save session after successful response
962                        let msgs = messages_guard.clone();
963                        let input_tokens = *total_input_tokens.lock().unwrap();
964                        let output_tokens = *total_output_tokens.lock().unwrap();
965
966                        if let Err(e) = session_manager.lock().unwrap().save_session(
967                            &session_id,
968                            &msgs,
969                            input_tokens,
970                            output_tokens,
971                        ) {
972                            tracing::error!("✗ Failed to auto-save session {}: {}", session_id, e);
973                        } else {
974                            tracing::info!(
975                                "✓ Session {} auto-saved ({} messages, {} in, {} out tokens)",
976                                session_id,
977                                msgs.len(),
978                                input_tokens,
979                                output_tokens
980                            );
981                        }
982                    }
983                    Err(e) => {
984                        // Check if it was a cancellation
985                        let error_msg = e.to_string();
986                        if error_msg.contains("cancelled") {
987                            tracing::info!("Request cancelled by user");
988                        } else {
989                            tracing::error!("LLM error: {}", e);
990                        }
991                    }
992                }
993
994                // Clear cancellation token
995                bridge.clear_cancellation_token();
996            });
997        });
998
999        Ok(())
1000    }
1001
1002    /// Cancel current LLM operation
1003    fn cancel_current_operation(&mut self) {
1004        if let Some(ref token) = self.cancellation_token {
1005            token.cancel();
1006            tracing::debug!("Cancellation token triggered");
1007
1008            // Increment operation ID to ignore subsequent events from old operation
1009            self.tui_bridge.next_operation_id();
1010
1011            // Force reset TUI state to Idle
1012            self.tui_bridge.set_state(TuiState::Idle);
1013
1014            // Update UI state
1015            self.status_message = "Operation cancelled".to_string();
1016            self.status_is_error = false;
1017
1018            // Clear activity feed
1019            self.tui_bridge
1020                .activity_feed()
1021                .lock()
1022                .unwrap()
1023                .complete_all();
1024
1025            // Add cancellation message to chat
1026            let cancel_msg = Message::system("⚠ Operation cancelled by user".to_string());
1027            self.tui_bridge
1028                .chat_view()
1029                .lock()
1030                .unwrap()
1031                .add_message(cancel_msg);
1032        }
1033        self.cancellation_token = None;
1034        // Reset ESC time via input_handler
1035        self.input_handler.reset_esc_time();
1036    }
1037
1038    fn draw(&mut self) -> Result<(), CliError> {
1039        let chat_view = self.tui_bridge.chat_view().clone();
1040        let display_text = self.input_editor.display_text_combined();
1041        let cursor_pos = self.input_editor.cursor();
1042        let cursor_blink_state = self.input_handler.cursor_blink_state();
1043        let tui_bridge = &self.tui_bridge;
1044        let file_autocomplete = self.autocomplete_manager.to_legacy_state();
1045
1046        self.terminal
1047            .draw(|f| {
1048                UiRenderer::render(
1049                    f,
1050                    f.area(),
1051                    &chat_view,
1052                    &display_text,
1053                    cursor_pos,
1054                    &self.status_message,
1055                    self.status_is_error,
1056                    cursor_blink_state,
1057                    tui_bridge,
1058                    &file_autocomplete,
1059                );
1060            })
1061            .map_err(|e| CliError::IoError(io::Error::other(e)))?;
1062
1063        Ok(())
1064    }
1065}
1066
1067#[cfg(test)]
1068mod tests {
1069    use super::*;
1070    use crate::agent_bridge::AgentBridge;
1071    use crate::tui::bridge::TuiBridge;
1072    use tokio::sync::mpsc;
1073
1074    /// Create a test config for AgentBridge
1075    fn create_test_config() -> limit_llm::Config {
1076        use limit_llm::{BrowserConfigSection, ProviderConfig};
1077        let mut providers = std::collections::HashMap::new();
1078        providers.insert(
1079            "anthropic".to_string(),
1080            ProviderConfig {
1081                api_key: Some("test-key".to_string()),
1082                model: "claude-3-5-sonnet-20241022".to_string(),
1083                base_url: None,
1084                max_tokens: 4096,
1085                timeout: 60,
1086                max_iterations: 100,
1087                thinking_enabled: false,
1088                clear_thinking: true,
1089            },
1090        );
1091        limit_llm::Config {
1092            provider: "anthropic".to_string(),
1093            providers,
1094            browser: BrowserConfigSection::default(),
1095        }
1096    }
1097
1098    #[test]
1099    fn test_tui_app_new() {
1100        let config = create_test_config();
1101        let agent_bridge = AgentBridge::new(config).unwrap();
1102        let (_tx, rx) = mpsc::unbounded_channel();
1103
1104        let tui_bridge = TuiBridge::new(agent_bridge, rx).unwrap();
1105        let app = TuiApp::new(tui_bridge);
1106        assert!(app.is_ok());
1107    }
1108}