Skip to main content

limit_cli/tui/app/
app_impl.rs

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