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    /// Pending image attachments (from clipboard paste)
47    pending_images: Vec<std::path::PathBuf>,
48    /// Input queue for managing messages during async operations
49    input_queue: crate::tui::input_queue::InputQueue,
50}
51
52impl TuiApp {
53    /// Create a new TUI application
54    pub fn new(tui_bridge: TuiBridge) -> Result<Self, CliError> {
55        let backend = CrosstermBackend::new(io::stdout());
56        let terminal =
57            Terminal::new(backend).map_err(|e| CliError::IoError(io::Error::other(e)))?;
58
59        let session_id = tui_bridge.session_id();
60        tracing::info!("TUI started with session: {}", session_id);
61
62        // Initialize history path (~/.limit/input_history.bin)
63        let home_dir = dirs::home_dir()
64            .ok_or_else(|| CliError::ConfigError("Failed to get home directory".to_string()))?;
65        let limit_dir = home_dir.join(".limit");
66        let history_path = limit_dir.join("input_history.bin");
67
68        // Load input editor with history
69        let input_editor = InputEditor::with_history(&history_path)
70            .map_err(|e| CliError::ConfigError(format!("Failed to load input history: {}", e)))?;
71
72        let clipboard = match ClipboardManager::new() {
73            Ok(cb) => {
74                tracing::info!("Clipboard initialized successfully");
75                Some(Arc::new(Mutex::new(cb)))
76            }
77            Err(e) => {
78                tracing::debug!("✗ Clipboard initialization failed: {}", e);
79                tracing::warn!("Clipboard unavailable: {}", e);
80                None
81            }
82        };
83
84        // Initialize autocomplete manager with current directory
85        let working_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
86        let autocomplete_manager = FileAutocompleteManager::new(working_dir);
87
88        // Initialize command registry
89        let command_registry = crate::tui::commands::create_default_registry();
90
91        Ok(Self {
92            tui_bridge,
93            terminal,
94            running: true,
95            input_editor,
96            history_path,
97            status_message: "Ready - Type a message and press Enter".to_string(),
98            status_is_error: false,
99            mouse_selection_start: None,
100            clipboard,
101            autocomplete_manager,
102            cancellation_token: None,
103            input_handler: InputHandler::new(),
104            command_registry,
105            pending_images: Vec::new(),
106            input_queue: crate::tui::input_queue::InputQueue::new(),
107        })
108    }
109
110    /// Run the TUI event loop
111    pub fn run(&mut self) -> Result<(), CliError> {
112        // Enter alternate screen - creates a clean buffer for TUI
113        execute!(std::io::stdout(), EnterAlternateScreen)
114            .map_err(|e| CliError::IoError(io::Error::other(e)))?;
115
116        // Enable mouse capture for scroll support
117        execute!(std::io::stdout(), EnableMouseCapture)
118            .map_err(|e| CliError::IoError(io::Error::other(e)))?;
119
120        // Enable bracketed paste for multi-line paste support
121        execute!(std::io::stdout(), EnableBracketedPaste)
122            .map_err(|e| CliError::IoError(io::Error::other(e)))?;
123
124        crossterm::terminal::enable_raw_mode()
125            .map_err(|e| CliError::IoError(io::Error::other(e)))?;
126
127        // Guard to ensure cleanup on panic
128        struct AlternateScreenGuard;
129        impl Drop for AlternateScreenGuard {
130            fn drop(&mut self) {
131                let _ = crossterm::terminal::disable_raw_mode();
132                let _ = execute!(std::io::stdout(), DisableBracketedPaste);
133                let _ = execute!(std::io::stdout(), DisableMouseCapture);
134                let _ = execute!(std::io::stdout(), LeaveAlternateScreen);
135            }
136        }
137        let _guard = AlternateScreenGuard;
138
139        self.run_inner()
140    }
141
142    fn run_inner(&mut self) -> Result<(), CliError> {
143        while self.running {
144            // Process events from the agent
145            self.tui_bridge.process_events()?;
146
147            // Update spinner if in thinking state
148            if matches!(self.tui_bridge.state(), TuiState::Thinking) {
149                self.tui_bridge.tick_spinner();
150            }
151
152            // Update status based on state
153            self.update_status();
154
155            // Handle user input with poll timeout
156            if event::poll(Duration::from_millis(100))
157                .map_err(|e| CliError::IoError(io::Error::other(e)))?
158            {
159                match event::read().map_err(|e| CliError::IoError(io::Error::other(e)))? {
160                    Event::Key(key) => {
161                        if key.kind == KeyEventKind::Press {
162                            self.handle_key_event(key)?;
163                        }
164                    }
165                    Event::Mouse(mouse) => {
166                        match mouse.kind {
167                            MouseEventKind::Down(MouseButton::Left) => {
168                                self.mouse_selection_start = Some((mouse.column, mouse.row));
169                                // Map screen position to message/offset and start selection
170                                let chat = self.tui_bridge.chat_view().lock().unwrap();
171                                if let Some((msg_idx, char_offset)) =
172                                    chat.screen_to_text_pos(mouse.column, mouse.row)
173                                {
174                                    drop(chat);
175                                    self.tui_bridge
176                                        .chat_view()
177                                        .lock()
178                                        .unwrap()
179                                        .start_selection(msg_idx, char_offset);
180                                } else {
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        // Save input history before exiting
241        if let Err(e) = self.input_editor.save_history(&self.history_path) {
242            tracing::error!("Failed to save input history: {}", e);
243        }
244
245        Ok(())
246    }
247
248    fn update_status(&mut self) {
249        // Don't override status if showing image attachment message
250        if self.status_message.starts_with("Image attached") {
251            return;
252        }
253
254        let session_id = self.tui_bridge.session_id();
255        let has_activity = self
256            .tui_bridge
257            .activity_feed()
258            .lock()
259            .unwrap()
260            .has_in_progress();
261
262        match self.tui_bridge.state() {
263            TuiState::Idle => {
264                // Send queued messages when transitioning to idle
265                self.maybe_send_next_queued_input();
266
267                if has_activity {
268                    // Show spinner when there are in-progress activities
269                    let spinner = self.tui_bridge.spinner().lock().unwrap();
270                    self.status_message = format!("{} Processing...", spinner.current_frame());
271                } else if self.input_queue.has_queued_messages()
272                    || self.input_queue.has_pending_steers()
273                {
274                    // Show queue status
275                    let queued = self.input_queue.queued_count();
276                    let steers = self.input_queue.steer_count();
277                    let mut parts = vec![];
278                    if queued > 0 {
279                        parts.push(format!("{} queued", queued));
280                    }
281                    if steers > 0 {
282                        parts.push(format!("{} pending", steers));
283                    }
284                    self.status_message =
285                        format!("Ready | {} message(s) in queue", parts.join(", "));
286                } else {
287                    self.status_message = format!(
288                        "Ready | Session: {}",
289                        session_id.chars().take(8).collect::<String>()
290                    );
291                }
292                self.status_is_error = false;
293            }
294            TuiState::Thinking => {
295                let spinner = self.tui_bridge.spinner().lock().unwrap();
296                if self.input_queue.has_queued_messages() || self.input_queue.has_pending_steers() {
297                    let queued = self.input_queue.queued_count();
298                    let steers = self.input_queue.steer_count();
299                    let mut parts = vec![];
300                    if queued > 0 {
301                        parts.push(format!("{} queued", queued));
302                    }
303                    if steers > 0 {
304                        parts.push(format!("{} pending", steers));
305                    }
306                    self.status_message = format!(
307                        "{} Thinking... | {} message(s) waiting",
308                        spinner.current_frame(),
309                        parts.join(", ")
310                    );
311                } else {
312                    self.status_message = format!("{} Thinking...", spinner.current_frame());
313                }
314                self.status_is_error = false;
315            }
316        }
317    }
318
319    /// Insert pasted text at cursor position without submitting
320    fn insert_paste(&mut self, text: &str) {
321        let truncated = self.input_editor.insert_paste(text);
322        if truncated {
323            self.status_message = "Paste truncated (too large)".to_string();
324            self.status_is_error = true;
325        }
326    }
327
328    /// Check if the current key event is a copy/paste shortcut
329    /// Returns true for Ctrl+C/V on Linux/Windows, Cmd+C/V on macOS
330    /// Note: Some macOS terminals report Cmd as CONTROL instead of SUPER
331    fn is_copy_paste_modifier(&self, key: &KeyEvent, char: char) -> bool {
332        #[cfg(target_os = "macos")]
333        {
334            // Accept both SUPER and CONTROL on macOS since terminal emulators vary
335            let has_super = key.modifiers.contains(KeyModifiers::SUPER);
336            let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
337            let result = key.code == KeyCode::Char(char) && (has_super || has_ctrl);
338            tracing::trace!("is_copy_paste_modifier('{}') macOS: code={:?}, mod={:?}, super={}, ctrl={}, result={}",
339                char, key.code, key.modifiers, has_super, has_ctrl, result);
340            result
341        }
342        #[cfg(not(target_os = "macos"))]
343        {
344            let result =
345                key.code == KeyCode::Char(char) && key.modifiers.contains(KeyModifiers::CONTROL);
346            tracing::trace!(
347                "is_copy_paste_modifier('{}') non-macOS: code={:?}, mod={:?}, ctrl={:?}, result={}",
348                char,
349                key.code,
350                key.modifiers,
351                KeyModifiers::CONTROL,
352                result
353            );
354            result
355        }
356    }
357
358    fn tick_cursor_blink(&mut self) {
359        // Delegate to InputHandler
360        self.input_handler.tick_cursor_blink();
361    }
362
363    fn handle_key_event(&mut self, key: KeyEvent) -> Result<(), CliError> {
364        // Copy selection to clipboard (Ctrl/Cmd+C)
365        if self.is_copy_paste_modifier(&key, 'c') {
366            tracing::trace!("✓ Copy shortcut CONFIRMED - processing...");
367            let mut chat = self.tui_bridge.chat_view().lock().unwrap();
368            let has_selection = chat.has_selection();
369            tracing::trace!("has_selection={}", has_selection);
370
371            if has_selection {
372                if let Some(selected) = chat.get_selected_text() {
373                    tracing::trace!("Selected text length={}", selected.len());
374                    if !selected.is_empty() {
375                        if let Some(ref clipboard) = self.clipboard {
376                            tracing::trace!("Attempting to copy to clipboard...");
377                            match clipboard.lock().unwrap().set_text(&selected) {
378                                Ok(()) => {
379                                    tracing::trace!("✓ Clipboard copy successful");
380                                    self.status_message = "Copied to clipboard".to_string();
381                                    self.status_is_error = false;
382                                }
383                                Err(e) => {
384                                    tracing::debug!("✗ Clipboard copy failed: {}", e);
385                                    self.status_message = format!("Clipboard error: {}", e);
386                                    self.status_is_error = true;
387                                }
388                            }
389                        } else {
390                            tracing::debug!("✗ Clipboard not available (None)");
391                            self.status_message = "Clipboard not available".to_string();
392                            self.status_is_error = true;
393                        }
394                    } else {
395                        tracing::trace!("Selected text is empty");
396                    }
397                    chat.clear_selection();
398                } else {
399                    tracing::trace!("get_selected_text() returned None");
400                }
401                return Ok(());
402            }
403
404            // No selection - do nothing (Ctrl+C is only for copying text)
405            tracing::trace!("Ctrl/Cmd+C with no selection - ignoring");
406            return Ok(());
407        }
408
409        // Paste from clipboard (Ctrl/Cmd+V or Alt+V for image)
410        // On macOS, Alt+V is handled separately below
411        let is_paste_shortcut = {
412            #[cfg(target_os = "macos")]
413            {
414                let has_mod = key.modifiers.contains(KeyModifiers::SUPER)
415                    || key.modifiers.contains(KeyModifiers::CONTROL);
416                let is_v = key.code == KeyCode::Char('v');
417                is_v && has_mod
418            }
419            #[cfg(not(target_os = "macos"))]
420            {
421                self.is_copy_paste_modifier(&key, 'v')
422            }
423        };
424
425        // Alt+V for image paste (macOS and Linux)
426        if key.code == KeyCode::Char('v')
427            && key.modifiers.contains(KeyModifiers::ALT)
428            && !self.tui_bridge.is_busy()
429        {
430            tracing::trace!("Attempting image paste (Alt+V)...");
431
432            // Check if current provider supports vision
433            let model = self
434                .tui_bridge
435                .agent_bridge_arc()
436                .lock()
437                .unwrap()
438                .model()
439                .to_lowercase();
440            let provider = self
441                .tui_bridge
442                .agent_bridge_arc()
443                .lock()
444                .unwrap()
445                .provider_name()
446                .to_lowercase();
447
448            let supports_vision = {
449                // OpenAI vision models
450                if provider == "openai" || provider == "openai-compatible" {
451                    model.contains("gpt-4o")
452                        || model.contains("gpt-4-turbo")
453                        || model.contains("gpt-4-vision")
454                // Anthropic Claude 3+ all support vision
455                } else if provider == "anthropic" || provider == "claude" {
456                    model.contains("claude-3")
457                // Google Gemini models
458                } else if provider == "google" || provider == "gemini" {
459                    model.contains("gemini")
460                // z.ai and other unsupported providers
461                } else {
462                    false
463                }
464            };
465
466            if !supports_vision {
467                self.status_message = "Current provider/model does not support images. Use a vision-capable model like gpt-4o or claude-3.".to_string();
468                self.status_is_error = true;
469                return Ok(());
470            }
471
472            match crate::clipboard_paste::paste_image_to_temp_png() {
473                Ok((_path, info)) => {
474                    tracing::debug!(
475                        "pasted image size={}x{} format={}",
476                        info.width,
477                        info.height,
478                        info.encoded_format.label()
479                    );
480                    self.pending_images.push(_path);
481                    self.status_message = format!(
482                        "Image attached ({}x{}) - {} image(s) pending. Press Enter to send.",
483                        info.width,
484                        info.height,
485                        self.pending_images.len()
486                    );
487                    self.status_is_error = false;
488                }
489                Err(err) => {
490                    tracing::warn!("failed to paste image: {err}");
491                    self.status_message = format!("Failed to paste image: {err}");
492                    self.status_is_error = true;
493                }
494            }
495            return Ok(());
496        }
497
498        // Regular text paste (Ctrl/Cmd+V)
499        if is_paste_shortcut && !self.tui_bridge.is_busy() {
500            let clipboard_result = if let Some(ref clipboard) = self.clipboard {
501                tracing::trace!("Attempting to read from clipboard...");
502                Some(clipboard.lock().unwrap().get_text())
503            } else {
504                None
505            };
506
507            match clipboard_result {
508                Some(Ok(text)) if !text.is_empty() => {
509                    tracing::trace!("Read {} chars from clipboard", text.len());
510                    self.insert_paste(&text);
511                }
512                Some(Ok(_)) => {
513                    tracing::trace!("Clipboard is empty");
514                }
515                Some(Err(e)) => {
516                    tracing::debug!("✗ Failed to read clipboard: {}", e);
517                    self.status_message = format!("Could not read clipboard: {}", e);
518                    self.status_is_error = true;
519                }
520                None => {
521                    tracing::debug!("✗ Clipboard not available (None)");
522                    self.status_message = "Clipboard not available".to_string();
523                    self.status_is_error = true;
524                }
525            }
526            return Ok(());
527        }
528
529        // Handle autocomplete navigation FIRST (before general scrolling)
530        let autocomplete_active = self.autocomplete_manager.is_active();
531        tracing::trace!(
532            "Key handling: autocomplete_active={}, is_busy={}, history_len={}",
533            autocomplete_active,
534            self.tui_bridge.is_busy(),
535            self.input_editor.history().len()
536        );
537
538        if autocomplete_active {
539            match key.code {
540                KeyCode::Up => {
541                    self.autocomplete_manager.navigate_up();
542                    return Ok(());
543                }
544                KeyCode::Down => {
545                    self.autocomplete_manager.navigate_down();
546                    return Ok(());
547                }
548                KeyCode::Enter | KeyCode::Tab => {
549                    self.accept_file_completion();
550                    return Ok(());
551                }
552                KeyCode::Esc => {
553                    self.autocomplete_manager.deactivate();
554                    return Ok(());
555                }
556                _ => {}
557            }
558        }
559
560        // Handle ESC for cancellation (must be before is_busy check)
561        if key.code == KeyCode::Esc {
562            // If autocomplete is active, cancel it
563            if self.autocomplete_manager.is_active() {
564                self.autocomplete_manager.deactivate();
565            } else if self.tui_bridge.is_busy() {
566                // Double-ESC to cancel current operation
567                let now = Instant::now();
568                let last_esc_time = self.input_handler.last_esc_time();
569                let should_cancel = if let Some(last_esc) = last_esc_time {
570                    now.duration_since(last_esc) < Duration::from_millis(1000)
571                } else {
572                    false
573                };
574
575                if should_cancel {
576                    self.cancel_current_operation();
577                } else {
578                    // First ESC - show feedback
579                    self.status_message = "Press ESC again to cancel".to_string();
580                    self.status_is_error = false;
581                    self.input_handler.set_last_esc_time(now);
582                }
583            } else {
584                tracing::debug!("Esc pressed, exiting");
585                self.running = false;
586            }
587            return Ok(());
588        }
589
590        // Allow scrolling even when agent is busy (PageUp/PageDown only)
591        // Calculate actual viewport height dynamically
592        let term_height = self.terminal.size().map(|s| s.height).unwrap_or(24);
593        let viewport_height = term_height
594            .saturating_sub(1) // status bar - input area (6 lines) - borders (~2)
595            .saturating_sub(7); // status (1) + input (6) + top/bottom borders (2) = 9
596        match key.code {
597            KeyCode::PageUp => {
598                let mut chat = self.tui_bridge.chat_view().lock().unwrap();
599                chat.scroll_page_up(viewport_height);
600                return Ok(());
601            }
602            KeyCode::PageDown => {
603                let mut chat = self.tui_bridge.chat_view().lock().unwrap();
604                chat.scroll_page_down(viewport_height);
605                return Ok(());
606            }
607            KeyCode::Up => {
608                // Use for history navigation
609                tracing::debug!(
610                    "Up arrow: navigating history up, history_len={}",
611                    self.input_editor.history().len()
612                );
613                let navigated = self.input_editor.navigate_history_up();
614                tracing::debug!(
615                    "Up arrow: navigated={}, text='{}'",
616                    navigated,
617                    self.input_editor.text()
618                );
619                return Ok(());
620            }
621            KeyCode::Down => {
622                // Use for history navigation
623                tracing::debug!(
624                    "Down arrow: navigating history down, is_navigating={}",
625                    self.input_editor.is_navigating_history()
626                );
627                let navigated = self.input_editor.navigate_history_down();
628                tracing::debug!(
629                    "Down arrow: navigated={}, text='{}'",
630                    navigated,
631                    self.input_editor.text()
632                );
633                return Ok(());
634            }
635            _ => {}
636        }
637
638        // Don't accept input while agent is busy - queue messages instead
639        if self.tui_bridge.is_busy() {
640            // Allow character input and queue it
641            match key.code {
642                KeyCode::Char(c)
643                    if key.modifiers == KeyModifiers::NONE
644                        || key.modifiers == KeyModifiers::SHIFT =>
645                {
646                    // Insert character
647                    self.input_editor.insert_char(c);
648                    tracing::debug!("Agent busy, queuing character: {}", c);
649                }
650                KeyCode::Backspace => {
651                    self.delete_char_before_cursor();
652                }
653                KeyCode::Delete => {
654                    self.input_editor.delete_char_at();
655                }
656                KeyCode::Left => {
657                    self.input_editor.move_left();
658                }
659                KeyCode::Right => {
660                    self.input_editor.move_right();
661                }
662                KeyCode::Home => {
663                    self.input_editor.move_to_start();
664                }
665                KeyCode::End => {
666                    self.input_editor.move_to_end();
667                }
668                KeyCode::Enter => {
669                    // Queue the message
670                    let text = self.input_editor.take_and_add_to_history();
671                    if !text.is_empty() {
672                        self.input_queue.queue_message(text);
673                        tracing::info!("Message queued while agent is busy");
674                    }
675                }
676                KeyCode::Esc => {
677                    // If we have pending steers, mark for immediate submit after interrupt
678                    if self.input_queue.has_pending_steers() {
679                        self.input_queue.set_submit_after_interrupt(true);
680                        self.cancel_current_operation();
681                        tracing::info!("Interrupting with pending steers to send immediately");
682                    }
683                }
684                _ => {}
685            }
686            return Ok(());
687        }
688
689        // Handle backspace - try multiple detection methods
690        if self.handle_backspace(&key) {
691            tracing::debug!("Backspace handled, input: {:?}", self.input_editor.text());
692            return Ok(());
693        }
694
695        match key.code {
696            KeyCode::Delete => {
697                if self.input_editor.delete_char_at() {
698                    tracing::debug!("Delete: input now: {:?}", self.input_editor.text());
699                }
700            }
701            KeyCode::Left => {
702                tracing::debug!(
703                    "KeyCode::Left: has_pasted_content={}",
704                    self.input_editor.has_pasted_content()
705                );
706                self.input_editor.move_left();
707            }
708            KeyCode::Right => {
709                tracing::debug!(
710                    "KeyCode::Right: has_pasted_content={}",
711                    self.input_editor.has_pasted_content()
712                );
713                self.input_editor.move_right();
714            }
715            KeyCode::Home => {
716                self.input_editor.move_to_start();
717            }
718            KeyCode::End => {
719                self.input_editor.move_to_end();
720            }
721            KeyCode::Enter => {
722                // If autocomplete is active, it's already handled above
723                self.handle_enter()?;
724            }
725            // Regular character input (including UTF-8)
726            KeyCode::Char(c)
727                if key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT =>
728            {
729                // Check if we're triggering autocomplete with @
730                if c == '@' {
731                    // Insert @ character
732                    self.input_editor.insert_char('@');
733
734                    // Activate autocomplete
735                    self.activate_file_autocomplete();
736                } else if self.autocomplete_manager.is_active() {
737                    // Update autocomplete and insert character
738                    self.autocomplete_manager.append_char(c);
739                    self.input_editor.insert_char(c);
740                } else {
741                    // Normal character insertion
742                    self.input_editor.insert_char(c);
743                }
744            }
745            _ => {
746                // Ignore other keys
747            }
748        }
749
750        Ok(())
751    }
752
753    /// Handle backspace with multiple detection methods
754    fn handle_backspace(&mut self, key: &KeyEvent) -> bool {
755        // Method 1: Standard Backspace keycode
756        if key.code == KeyCode::Backspace {
757            tracing::debug!("Backspace detected via KeyCode::Backspace");
758            self.delete_char_before_cursor();
759            return true;
760        }
761
762        // Method 2: Ctrl+H (common backspace mapping)
763        if key.code == KeyCode::Char('h') && key.modifiers == KeyModifiers::CONTROL {
764            tracing::debug!("Backspace detected via Ctrl+H");
765            self.delete_char_before_cursor();
766            return true;
767        }
768
769        // Method 3: Check for DEL (127) or BS (8) characters
770        if let KeyCode::Char(c) = key.code {
771            if c == '\x7f' || c == '\x08' {
772                tracing::debug!("Backspace detected via char code: {}", c as u8);
773                self.delete_char_before_cursor();
774                return true;
775            }
776        }
777
778        false
779    }
780
781    fn delete_char_before_cursor(&mut self) {
782        tracing::debug!(
783            "delete_char: cursor={}, len={}, input={:?}",
784            self.input_editor.cursor(),
785            self.input_editor.text().len(),
786            self.input_editor.text()
787        );
788
789        // If autocomplete is active, handle backspace specially
790        if self.autocomplete_manager.is_active() {
791            let should_close = self.autocomplete_manager.backspace();
792
793            if should_close
794                && self.input_editor.cursor() > 0
795                && self.input_editor.char_before_cursor() == Some('@')
796            {
797                self.input_editor.delete_char_before();
798                self.autocomplete_manager.deactivate();
799                return;
800            }
801        }
802
803        // Normal backspace
804        self.input_editor.delete_char_before();
805    }
806
807    /// Activate file autocomplete
808    fn activate_file_autocomplete(&mut self) {
809        let trigger_pos = self.input_editor.cursor() - 1; // Position of @
810        self.autocomplete_manager.activate(trigger_pos);
811
812        tracing::info!(
813            "🔍 ACTIVATED AUTOCOMPLETE: trigger_pos={}, cursor={}, text='{}'",
814            trigger_pos,
815            self.input_editor.cursor(),
816            self.input_editor.text()
817        );
818    }
819
820    /// Accept selected file completion
821    fn accept_file_completion(&mut self) {
822        // Get the selected match WITHOUT using accept_completion()
823        // We do this manually to avoid the trailing space issue
824        let selected = self.autocomplete_manager.selected_match().cloned();
825
826        if let Some(selected) = selected {
827            let trigger_pos = self.autocomplete_manager.trigger_pos().unwrap_or(0);
828            let current_cursor = self.input_editor.cursor();
829
830            tracing::info!(
831                "🎯 ACCEPT: trigger_pos={}, cursor={}, path='{}'",
832                trigger_pos,
833                current_cursor,
834                selected.path
835            );
836
837            // Calculate what to remove: from @+1 to current cursor
838            let remove_start = trigger_pos + 1;
839            let remove_end = current_cursor;
840
841            tracing::info!(
842                "🎯 REMOVE RANGE: {}..{} = '{}'",
843                remove_start,
844                remove_end,
845                &self.input_editor.text()
846                    [remove_start..remove_end.min(self.input_editor.text().len())]
847            );
848
849            // Use replace_range to atomically replace the query with the completion
850            // Use selected.path WITHOUT trailing space
851            if remove_end > remove_start {
852                self.input_editor
853                    .replace_range(remove_start, remove_end, &selected.path);
854            } else {
855                // Nothing to remove, just insert after @
856                self.input_editor.set_cursor(remove_start);
857                self.input_editor.insert_str(&selected.path);
858            }
859
860            // Add trailing space AFTER the completion
861            self.input_editor.insert_char(' ');
862
863            tracing::info!(
864                "🎯 FINAL: '{}', cursor={}",
865                self.input_editor.text(),
866                self.input_editor.cursor()
867            );
868        }
869
870        // Close autocomplete
871        self.autocomplete_manager.deactivate();
872    }
873
874    fn handle_enter(&mut self) -> Result<(), CliError> {
875        let text = self.input_editor.take_and_add_to_history();
876
877        if text.is_empty() {
878            return Ok(());
879        }
880
881        tracing::info!("Enter pressed with text: {:?}", text);
882
883        // Check if it's a command (starts with /)
884        if text.starts_with('/') {
885            use crate::tui::commands::{CommandContext, CommandResult};
886
887            let mut cmd_ctx = CommandContext::new(
888                self.tui_bridge.chat_view().clone(),
889                self.tui_bridge.session_manager(),
890                self.tui_bridge.session_id(),
891                self.tui_bridge.state_arc(),
892                self.tui_bridge.messages(),
893                self.tui_bridge.total_input_tokens_arc(),
894                self.tui_bridge.total_output_tokens_arc(),
895                self.clipboard.clone(),
896                self.autocomplete_manager.base_path().to_path_buf(),
897            );
898
899            // Execute command via registry
900            match self.command_registry.parse_and_execute(&text, &mut cmd_ctx) {
901                Ok(Some(result)) => {
902                    // Update session_id if changed by command
903                    self.tui_bridge
904                        .session_id_arc()
905                        .lock()
906                        .unwrap()
907                        .clone_from(&cmd_ctx.session_id);
908
909                    match result {
910                        CommandResult::Exit => {
911                            self.running = false;
912                            return Ok(());
913                        }
914                        CommandResult::ClearChat => {
915                            // Already handled by command
916                            return Ok(());
917                        }
918                        CommandResult::NewSession | CommandResult::LoadSession(_) => {
919                            // Session was changed, sync state
920                            *self.tui_bridge.messages().lock().unwrap() =
921                                cmd_ctx.messages.lock().unwrap().clone();
922                            *self.tui_bridge.total_input_tokens_arc().lock().unwrap() =
923                                *cmd_ctx.total_input_tokens.lock().unwrap();
924                            *self.tui_bridge.total_output_tokens_arc().lock().unwrap() =
925                                *cmd_ctx.total_output_tokens.lock().unwrap();
926                            return Ok(());
927                        }
928                        CommandResult::Continue
929                        | CommandResult::Message(_)
930                        | CommandResult::Share(_) => {
931                            return Ok(());
932                        }
933                    }
934                }
935                Ok(None) => {
936                    // Not a command, fall through to LLM processing
937                }
938                Err(e) => {
939                    tracing::error!("Command error: {}", e);
940                    // Show error to user instead of crashing
941                    self.tui_bridge
942                        .chat_view()
943                        .lock()
944                        .unwrap()
945                        .add_message(Message::system(format!("Error: {}", e)));
946                    return Ok(());
947                }
948            }
949        }
950
951        // Handle bare commands (without /) for backwards compatibility
952        let text_lower = text.to_lowercase();
953        if text_lower == "exit" || text_lower == "quit" {
954            tracing::info!("Exit command detected, exiting");
955            self.running = false;
956            return Ok(());
957        }
958
959        if text_lower == "clear" {
960            tracing::info!("Clear command detected");
961            self.tui_bridge.chat_view().lock().unwrap().clear();
962            return Ok(());
963        }
964
965        if text_lower == "help" {
966            tracing::info!("Help command detected");
967            let help_msg = Message::system(
968                "Available commands:\n\
969                 /help  - Show this help message\n\
970                 /clear - Clear chat history\n\
971                 /exit  - Exit the application\n\
972                 /quit  - Exit the application\n\
973                 /session list  - List all sessions\n\
974                 /session new   - Create a new session\n\
975                 /session load  <id> - Load a session by ID\n\
976                 /share         - Copy session to clipboard (markdown)\n\
977                 /share md      - Export session as markdown file\n\
978                 /share json    - Export session as JSON file\n\
979                 \n\
980                 Page Up/Down - Scroll chat history\n\
981                 Up/Down (empty input) - Navigate input history"
982                    .to_string(),
983            );
984            self.tui_bridge
985                .chat_view()
986                .lock()
987                .unwrap()
988                .add_message(help_msg);
989            return Ok(());
990        }
991
992        // Add user message to chat immediately for visual feedback
993        // Check if we have pending images
994        let _content = if self.pending_images.is_empty() {
995            limit_llm::MessageContent::text(text.clone())
996        } else {
997            // Build multimodal content with text and images
998            let mut parts = vec![limit_llm::ContentPart::text(text.clone())];
999
1000            for image_path in self.pending_images.drain(..) {
1001                // Read image file and convert to base64
1002                match std::fs::read(&image_path) {
1003                    Ok(image_data) => {
1004                        // Detect image type from extension
1005                        let media_type = image_path
1006                            .extension()
1007                            .and_then(|e| e.to_str())
1008                            .map(|e| match e.to_lowercase().as_str() {
1009                                "png" => "image/png",
1010                                "jpg" | "jpeg" => "image/jpeg",
1011                                "gif" => "image/gif",
1012                                "webp" => "image/webp",
1013                                _ => "image/png",
1014                            })
1015                            .unwrap_or("image/png");
1016
1017                        let base64_data = base64::Engine::encode(
1018                            &base64::engine::general_purpose::STANDARD,
1019                            &image_data,
1020                        );
1021
1022                        parts.push(limit_llm::ContentPart::image_base64(
1023                            media_type,
1024                            &base64_data,
1025                        ));
1026
1027                        tracing::info!(
1028                            "Attached image: {} ({} bytes, {})",
1029                            image_path.display(),
1030                            image_data.len(),
1031                            media_type
1032                        );
1033                    }
1034                    Err(e) => {
1035                        tracing::error!("Failed to read image {}: {}", image_path.display(), e);
1036                    }
1037                }
1038            }
1039
1040            self.status_message = "Ready - Type a message and press Enter".to_string();
1041            limit_llm::MessageContent::parts(parts)
1042        };
1043
1044        self.tui_bridge.add_user_message(text.clone());
1045
1046        // Get new operation ID and ensure state is Idle
1047        let operation_id = self.tui_bridge.next_operation_id();
1048        tracing::debug!("handle_enter: new operation_id={}", operation_id);
1049        self.tui_bridge.set_state(TuiState::Idle);
1050
1051        // Create cancellation token for this operation
1052        let cancel_token = tokio_util::sync::CancellationToken::new();
1053        self.cancellation_token = Some(cancel_token.clone());
1054
1055        // Clone Arcs for the spawned thread
1056        let messages = self.tui_bridge.messages();
1057        let agent_bridge = self.tui_bridge.agent_bridge_arc();
1058        let session_manager = self.tui_bridge.session_manager();
1059        let session_id = self.tui_bridge.session_id();
1060        let total_input_tokens = self.tui_bridge.total_input_tokens_arc();
1061        let total_output_tokens = self.tui_bridge.total_output_tokens_arc();
1062
1063        tracing::debug!("Spawning LLM processing thread");
1064
1065        // Spawn a thread to process the message without blocking the UI
1066        std::thread::spawn(move || {
1067            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1068                // Create a new tokio runtime for this thread
1069                let rt = tokio::runtime::Runtime::new().unwrap();
1070
1071                // Safe: we're in a dedicated thread, this won't cause issues
1072                #[allow(clippy::await_holding_lock)]
1073                rt.block_on(async {
1074                    // Check for cancellation BEFORE acquiring locks
1075                    if cancel_token.is_cancelled() {
1076                        tracing::debug!("Operation cancelled before acquiring locks");
1077                        return;
1078                    }
1079
1080                    // Try to acquire locks with timeout to avoid blocking indefinitely
1081                    let messages_guard = {
1082                        let mut attempts = 0;
1083                        loop {
1084                            if cancel_token.is_cancelled() {
1085                                tracing::debug!(
1086                                    "Operation cancelled while waiting for messages lock"
1087                                );
1088                                return;
1089                            }
1090                            match messages.try_lock() {
1091                                Ok(guard) => break guard,
1092                                Err(std::sync::TryLockError::WouldBlock) => {
1093                                    attempts += 1;
1094                                    if attempts > 50 {
1095                                        tracing::error!("Timeout waiting for messages lock");
1096                                        return;
1097                                    }
1098                                    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1099                                }
1100                                Err(e) => {
1101                                    tracing::error!("Failed to lock messages: {}", e);
1102                                    return;
1103                                }
1104                            }
1105                        }
1106                    };
1107
1108                    let mut messages_guard = messages_guard;
1109
1110                    // Check cancellation again before acquiring bridge lock
1111                    if cancel_token.is_cancelled() {
1112                        tracing::debug!("Operation cancelled before acquiring bridge lock");
1113                        return;
1114                    }
1115
1116                    let bridge_guard = {
1117                        let mut attempts = 0;
1118                        loop {
1119                            if cancel_token.is_cancelled() {
1120                                tracing::debug!(
1121                                    "Operation cancelled while waiting for bridge lock"
1122                                );
1123                                return;
1124                            }
1125                            match agent_bridge.try_lock() {
1126                                Ok(guard) => break guard,
1127                                Err(std::sync::TryLockError::WouldBlock) => {
1128                                    attempts += 1;
1129                                    if attempts > 50 {
1130                                        tracing::error!("Timeout waiting for bridge lock");
1131                                        return;
1132                                    }
1133                                    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1134                                }
1135                                Err(e) => {
1136                                    tracing::error!("Failed to lock agent_bridge: {}", e);
1137                                    return;
1138                                }
1139                            }
1140                        }
1141                    };
1142
1143                    let mut bridge = bridge_guard;
1144
1145                    // Set cancellation token and operation ID
1146                    bridge.set_cancellation_token(cancel_token.clone(), operation_id);
1147
1148                    match bridge.process_message(&text, &mut messages_guard).await {
1149                        Ok(result) => {
1150                            {
1151                                let mut input = total_input_tokens.lock().unwrap();
1152                                let mut output = total_output_tokens.lock().unwrap();
1153                                *input += result.input_tokens;
1154                                *output += result.output_tokens;
1155                            }
1156
1157                            let msgs = messages_guard.clone();
1158                            let input_tokens = *total_input_tokens.lock().unwrap();
1159                            let output_tokens = *total_output_tokens.lock().unwrap();
1160
1161                            if let Err(e) = session_manager.lock().unwrap().save_session(
1162                                &session_id,
1163                                &msgs,
1164                                input_tokens,
1165                                output_tokens,
1166                            ) {
1167                                tracing::error!(
1168                                    "✗ Failed to auto-save session {}: {}",
1169                                    session_id,
1170                                    e
1171                                );
1172                            } else {
1173                                tracing::info!(
1174                                    "✓ Session {} auto-saved ({} messages, {} in, {} out tokens)",
1175                                    session_id,
1176                                    msgs.len(),
1177                                    input_tokens,
1178                                    output_tokens
1179                                );
1180                            }
1181                        }
1182                        Err(e) => {
1183                            // Check if it was a cancellation
1184                            let error_msg = e.to_string();
1185                            if error_msg.contains("cancelled") {
1186                                tracing::info!("Request cancelled by user");
1187                            } else {
1188                                tracing::error!("LLM error: {}", e);
1189                            }
1190                        }
1191                    }
1192
1193                    // Clear cancellation token
1194                    bridge.clear_cancellation_token();
1195                });
1196            }));
1197
1198            if let Err(panic_payload) = result {
1199                let msg = panic_payload
1200                    .downcast_ref::<&str>()
1201                    .copied()
1202                    .or_else(|| panic_payload.downcast_ref::<String>().map(|s| s.as_str()))
1203                    .unwrap_or("unknown panic");
1204                tracing::error!("LLM thread panicked: {}", msg);
1205            }
1206        });
1207
1208        Ok(())
1209    }
1210
1211    /// Cancel current LLM operation
1212    fn cancel_current_operation(&mut self) {
1213        if let Some(ref token) = self.cancellation_token {
1214            token.cancel();
1215            tracing::debug!("Cancellation token triggered");
1216
1217            // Increment operation ID to ignore subsequent events from old operation
1218            self.tui_bridge.next_operation_id();
1219
1220            // Check if we should send pending steers immediately after interrupt
1221            let should_send_steers = self.input_queue.should_submit_after_interrupt();
1222
1223            if should_send_steers {
1224                // Drain steers and send as new turn
1225                if let Some(merged) = self.input_queue.merge_all() {
1226                    tracing::info!(
1227                        "Sending pending steers immediately after interrupt: {}",
1228                        merged
1229                    );
1230
1231                    // Add user message to chat
1232                    self.tui_bridge.add_user_message(merged.clone());
1233
1234                    // TODO: Submit to agent - this would need to call handle_enter logic
1235                    // For now, just restore to input editor
1236                    self.input_editor.set_text(&merged);
1237                    self.status_message =
1238                        "Steers restored to input - press Enter to send".to_string();
1239                }
1240                self.input_queue.set_submit_after_interrupt(false);
1241            } else {
1242                // Force reset TUI state to Idle
1243                self.tui_bridge.set_state(TuiState::Idle);
1244
1245                // Update UI state
1246                self.status_message = "Operation cancelled".to_string();
1247                self.status_is_error = false;
1248
1249                // Clear activity feed
1250                self.tui_bridge
1251                    .activity_feed()
1252                    .lock()
1253                    .unwrap()
1254                    .complete_all();
1255
1256                // Add cancellation message to chat
1257                let cancel_msg = Message::system("⚠ Operation cancelled by user".to_string());
1258                self.tui_bridge
1259                    .chat_view()
1260                    .lock()
1261                    .unwrap()
1262                    .add_message(cancel_msg);
1263            }
1264        }
1265        self.cancellation_token = None;
1266        // Reset ESC time via input_handler
1267        self.input_handler.reset_esc_time();
1268    }
1269
1270    fn draw(&mut self) -> Result<(), CliError> {
1271        let chat_view = self.tui_bridge.chat_view().clone();
1272        let display_text = self.input_editor.display_text_combined();
1273        let cursor_pos = self.input_editor.cursor();
1274        let cursor_blink_state = self.input_handler.cursor_blink_state();
1275        let tui_bridge = &self.tui_bridge;
1276        let file_autocomplete = self.autocomplete_manager.to_legacy_state();
1277
1278        // Build pending input preview from queue
1279        let pending_input_preview =
1280            if self.input_queue.has_queued_messages() || self.input_queue.has_pending_steers() {
1281                let mut preview = limit_tui::components::PendingInputPreview::new();
1282                preview.pending_steers = self.input_queue.steer_texts();
1283                preview.queued_messages = self.input_queue.queued_texts();
1284                Some(preview)
1285            } else {
1286                None
1287            };
1288
1289        self.terminal
1290            .draw(|f| {
1291                UiRenderer::render(
1292                    f,
1293                    f.area(),
1294                    &chat_view,
1295                    &display_text,
1296                    cursor_pos,
1297                    &self.status_message,
1298                    self.status_is_error,
1299                    cursor_blink_state,
1300                    tui_bridge,
1301                    &file_autocomplete,
1302                    pending_input_preview.as_ref(),
1303                );
1304            })
1305            .map_err(|e| CliError::IoError(io::Error::other(e)))?;
1306
1307        Ok(())
1308    }
1309
1310    /// Send next queued input if available (called when transitioning to Idle)
1311    fn maybe_send_next_queued_input(&mut self) {
1312        if self.input_queue.is_autosend_suppressed() {
1313            return;
1314        }
1315
1316        if self.tui_bridge.is_busy() {
1317            return;
1318        }
1319
1320        // Pop the next queued message
1321        if let Some(msg) = self.input_queue.pop_queued() {
1322            tracing::info!("Sending queued message: {}", msg.text);
1323
1324            // Set the text directly in the editor
1325            self.input_editor.clear();
1326            for ch in msg.text.chars() {
1327                self.input_editor.insert_char(ch);
1328            }
1329
1330            // Add to history
1331            self.input_editor.history_mut().add(&msg.text);
1332
1333            // Trigger the message submission
1334            // This will handle the LLM processing in a separate thread
1335            drop(self.handle_enter());
1336        }
1337    }
1338}
1339
1340#[cfg(test)]
1341mod tests {
1342    use super::*;
1343    use crate::agent_bridge::AgentBridge;
1344    use crate::tui::bridge::TuiBridge;
1345    use std::io::IsTerminal;
1346    use tokio::sync::mpsc;
1347
1348    /// Create a test config for AgentBridge
1349    fn create_test_config() -> limit_llm::Config {
1350        use limit_llm::{BrowserConfigSection, ProviderConfig};
1351        let mut providers = std::collections::HashMap::new();
1352        providers.insert(
1353            "anthropic".to_string(),
1354            ProviderConfig {
1355                api_key: Some("test-key".to_string()),
1356                model: "claude-3-5-sonnet-20241022".to_string(),
1357                base_url: None,
1358                max_tokens: 4096,
1359                timeout: 60,
1360                max_iterations: 100,
1361                thinking_enabled: false,
1362                clear_thinking: true,
1363            },
1364        );
1365        limit_llm::Config {
1366            provider: "anthropic".to_string(),
1367            providers,
1368            browser: BrowserConfigSection::default(),
1369            compaction: limit_llm::CompactionSettings::default(),
1370            cache: limit_llm::CacheSettings::default(),
1371        }
1372    }
1373
1374    #[test]
1375    fn test_tui_app_new() {
1376        // Terminal::new() calls backend.size() which fails without a TTY
1377        if std::io::stdout().is_terminal() {
1378            let config = create_test_config();
1379            let agent_bridge = AgentBridge::new(config).unwrap();
1380            let (_tx, rx) = mpsc::unbounded_channel();
1381
1382            let tui_bridge = TuiBridge::new(agent_bridge, rx).unwrap();
1383            let app = TuiApp::new(tui_bridge);
1384            assert!(app.is_ok());
1385        }
1386    }
1387}