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