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