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