Skip to main content

limit_cli/
tui_bridge.rs

1use crate::agent_bridge::{AgentBridge, AgentEvent};
2use crate::clipboard::ClipboardManager;
3use crate::error::CliError;
4use crate::file_finder::FileFinder;
5use crate::session::SessionManager;
6use crossterm::event::{
7    self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
8    Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind,
9};
10use crossterm::execute;
11use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
12use limit_tui::components::{
13    calculate_popup_area, ActivityFeed, ChatView, FileAutocompleteWidget, FileMatchData, Message,
14    Spinner,
15};
16use ratatui::{
17    backend::CrosstermBackend,
18    layout::{Constraint, Direction, Layout},
19    style::{Color, Modifier, Style},
20    text::{Line, Span},
21    widgets::{Block, Borders, Paragraph, Wrap},
22    Frame, Terminal,
23};
24use std::io;
25use std::sync::{Arc, Mutex};
26use tokio::sync::mpsc;
27
28/// Maximum paste size to prevent memory issues (100KB)
29const MAX_PASTE_SIZE: usize = 100 * 1024;
30
31/// State for file autocomplete popup
32#[derive(Debug, Clone)]
33pub struct FileAutocompleteState {
34    /// Whether autocomplete popup is visible
35    pub is_active: bool,
36    /// Query typed after @ (e.g., "Cargo" in "@Cargo")
37    pub query: String,
38    /// Start position of @ in input_text
39    pub trigger_pos: usize,
40    /// List of matching files
41    pub matches: Vec<FileMatchData>,
42    /// Currently selected index in matches
43    pub selected_index: usize,
44}
45
46/// Debug log to file (bypasses tracing)
47fn debug_log(msg: &str) {
48    use std::fs::OpenOptions;
49    use std::io::Write;
50    if let Ok(mut file) = OpenOptions::new()
51        .create(true)
52        .append(true)
53        .open(std::env::var("HOME").unwrap_or_else(|_| ".".to_string()) + "/.limit/logs/tui.log")
54    {
55        let timestamp = chrono::Local::now().format("%H:%M:%S%.3f");
56        let _ = writeln!(file, "[{}] {}", timestamp, msg);
57    }
58}
59/// TUI state for displaying agent events
60#[derive(Debug, Clone, PartialEq, Default)]
61pub enum TuiState {
62    #[default]
63    Idle,
64    Thinking,
65}
66
67/// Bridge connecting limit-cli REPL to limit-tui components
68pub struct TuiBridge {
69    /// Agent bridge for processing messages (wrapped for thread-safe access)
70    agent_bridge: Arc<Mutex<AgentBridge>>,
71    /// Event receiver from the agent
72    event_rx: mpsc::UnboundedReceiver<AgentEvent>,
73    /// Current TUI state
74    state: Arc<Mutex<TuiState>>,
75    /// Chat view for displaying conversation
76    chat_view: Arc<Mutex<ChatView>>,
77    /// Activity feed for showing tool activities
78    activity_feed: Arc<Mutex<ActivityFeed>>,
79    /// Spinner for thinking state
80    /// Spinner for thinking state
81    spinner: Arc<Mutex<Spinner>>,
82    /// Conversation history
83    messages: Arc<Mutex<Vec<limit_llm::Message>>>,
84    /// Total input tokens for the session
85    total_input_tokens: Arc<Mutex<u64>>,
86    /// Total output tokens for the session
87    total_output_tokens: Arc<Mutex<u64>>,
88    /// Session manager for persistence
89    session_manager: Arc<Mutex<SessionManager>>,
90    /// Current session ID
91    session_id: Arc<Mutex<String>>,
92}
93
94impl TuiBridge {
95    /// Create a new TuiBridge with the given agent bridge and event channel
96    pub fn new(
97        agent_bridge: AgentBridge,
98        event_rx: mpsc::UnboundedReceiver<AgentEvent>,
99    ) -> Result<Self, CliError> {
100        let session_manager = SessionManager::new().map_err(|e| {
101            CliError::ConfigError(format!("Failed to create session manager: {}", e))
102        })?;
103
104        // Always create a new session on TUI startup
105        let session_id = session_manager
106            .create_new_session()
107            .map_err(|e| CliError::ConfigError(format!("Failed to create session: {}", e)))?;
108        tracing::info!("Created new TUI session: {}", session_id);
109
110        // Start with empty messages - never load previous session
111        let messages: Vec<limit_llm::Message> = Vec::new();
112
113        // Get token counts from session info
114        let sessions = session_manager.list_sessions().unwrap_or_default();
115        let session_info = sessions.iter().find(|s| s.id == session_id);
116        let initial_input = session_info.map(|s| s.total_input_tokens).unwrap_or(0);
117        let initial_output = session_info.map(|s| s.total_output_tokens).unwrap_or(0);
118
119        let chat_view = Arc::new(Mutex::new(ChatView::new()));
120
121        // Add loaded messages to chat view for display
122        for msg in &messages {
123            match msg.role {
124                limit_llm::Role::User => {
125                    let chat_msg = Message::user(msg.content.clone().unwrap_or_default());
126                    chat_view.lock().unwrap().add_message(chat_msg);
127                }
128                limit_llm::Role::Assistant => {
129                    let content = msg.content.clone().unwrap_or_default();
130                    let chat_msg = Message::assistant(content);
131                    chat_view.lock().unwrap().add_message(chat_msg);
132                }
133                limit_llm::Role::System => {
134                    // Skip system messages in display
135                }
136                limit_llm::Role::Tool => {
137                    // Skip tool messages in display
138                }
139            }
140        }
141
142        tracing::info!("Loaded {} messages into chat view", messages.len());
143
144        // Add system message to indicate this is a new session
145        let session_short_id = format!("...{}", &session_id[session_id.len().saturating_sub(8)..]);
146        let welcome_msg =
147            Message::system(format!("🆕 New TUI session started: {}", session_short_id));
148        chat_view.lock().unwrap().add_message(welcome_msg);
149
150        // Add model info as system message
151        let model_name = agent_bridge.model().to_string();
152        if !model_name.is_empty() {
153            let model_msg = Message::system(format!("Using model: {}", model_name));
154            chat_view.lock().unwrap().add_message(model_msg);
155        }
156
157        Ok(Self {
158            agent_bridge: Arc::new(Mutex::new(agent_bridge)),
159            event_rx,
160            state: Arc::new(Mutex::new(TuiState::Idle)),
161            chat_view,
162            activity_feed: Arc::new(Mutex::new(ActivityFeed::new())),
163            spinner: Arc::new(Mutex::new(Spinner::new("Thinking..."))),
164            messages: Arc::new(Mutex::new(messages)),
165            total_input_tokens: Arc::new(Mutex::new(initial_input)),
166            total_output_tokens: Arc::new(Mutex::new(initial_output)),
167            session_manager: Arc::new(Mutex::new(session_manager)),
168            session_id: Arc::new(Mutex::new(session_id)),
169        })
170    }
171
172    /// Get a clone of the agent bridge Arc for spawning tasks
173    pub fn agent_bridge_arc(&self) -> Arc<Mutex<AgentBridge>> {
174        self.agent_bridge.clone()
175    }
176
177    /// Get locked access to the agent bridge (for compatibility)
178    #[allow(dead_code)]
179    pub fn agent_bridge(&self) -> std::sync::MutexGuard<'_, AgentBridge> {
180        self.agent_bridge.lock().unwrap()
181    }
182
183    /// Get the current TUI state
184    pub fn state(&self) -> TuiState {
185        self.state.lock().unwrap().clone()
186    }
187
188    /// Get a reference to the chat view
189    pub fn chat_view(&self) -> &Arc<Mutex<ChatView>> {
190        &self.chat_view
191    }
192
193    /// Get a reference to the spinner
194    pub fn spinner(&self) -> &Arc<Mutex<Spinner>> {
195        &self.spinner
196    }
197
198    /// Get a reference to the activity feed
199    pub fn activity_feed(&self) -> &Arc<Mutex<ActivityFeed>> {
200        &self.activity_feed
201    }
202
203    /// Process events from the agent and update TUI state
204    pub fn process_events(&mut self) -> Result<(), CliError> {
205        while let Ok(event) = self.event_rx.try_recv() {
206            match event {
207                AgentEvent::Thinking => {
208                    *self.state.lock().unwrap() = TuiState::Thinking;
209                }
210                AgentEvent::ToolStart { name, args } => {
211                    let activity_msg = Self::format_activity_message(&name, &args);
212                    // Add to activity feed instead of changing state
213                    self.activity_feed.lock().unwrap().add(activity_msg, true);
214                }
215                AgentEvent::ToolComplete { name: _, result: _ } => {
216                    // Mark current activity as complete
217                    self.activity_feed.lock().unwrap().complete_current();
218                }
219                AgentEvent::ContentChunk(chunk) => {
220                    self.chat_view
221                        .lock()
222                        .unwrap()
223                        .append_to_last_assistant(&chunk);
224                }
225                AgentEvent::Done => {
226                    *self.state.lock().unwrap() = TuiState::Idle;
227                    // Mark all activities as complete when LLM finishes
228                    self.activity_feed.lock().unwrap().complete_all();
229                }
230                AgentEvent::Error(err) => {
231                    // Reset state to Idle so user can continue
232                    *self.state.lock().unwrap() = TuiState::Idle;
233                    let chat_msg = Message::system(format!("Error: {}", err));
234                    self.chat_view.lock().unwrap().add_message(chat_msg);
235                }
236                AgentEvent::TokenUsage {
237                    input_tokens,
238                    output_tokens,
239                } => {
240                    // Accumulate token counts for display
241                    *self.total_input_tokens.lock().unwrap() += input_tokens;
242                    *self.total_output_tokens.lock().unwrap() += output_tokens;
243                }
244            }
245        }
246        Ok(())
247    }
248
249    fn format_activity_message(tool_name: &str, args: &serde_json::Value) -> String {
250        match tool_name {
251            "file_read" => args
252                .get("path")
253                .and_then(|p| p.as_str())
254                .map(|p| format!("Reading {}...", Self::truncate_path(p, 40)))
255                .unwrap_or_else(|| "Reading file...".to_string()),
256            "file_write" => args
257                .get("path")
258                .and_then(|p| p.as_str())
259                .map(|p| format!("Writing {}...", Self::truncate_path(p, 40)))
260                .unwrap_or_else(|| "Writing file...".to_string()),
261            "file_edit" => args
262                .get("path")
263                .and_then(|p| p.as_str())
264                .map(|p| format!("Editing {}...", Self::truncate_path(p, 40)))
265                .unwrap_or_else(|| "Editing file...".to_string()),
266            "bash" => args
267                .get("command")
268                .and_then(|c| c.as_str())
269                .map(|c| format!("Running {}...", Self::truncate_command(c, 30)))
270                .unwrap_or_else(|| "Executing command...".to_string()),
271            "git_status" => "Checking git status...".to_string(),
272            "git_diff" => "Checking git diff...".to_string(),
273            "git_log" => "Checking git log...".to_string(),
274            "git_add" => "Staging files...".to_string(),
275            "git_commit" => "Creating commit...".to_string(),
276            "git_push" => "Pushing to remote...".to_string(),
277            "git_pull" => "Pulling from remote...".to_string(),
278            "git_clone" => args
279                .get("url")
280                .and_then(|u| u.as_str())
281                .map(|u| format!("Cloning {}...", Self::truncate_path(u, 40)))
282                .unwrap_or_else(|| "Cloning repository...".to_string()),
283            "grep" => args
284                .get("pattern")
285                .and_then(|p| p.as_str())
286                .map(|p| format!("Searching for '{}'...", Self::truncate_command(p, 30)))
287                .unwrap_or_else(|| "Searching...".to_string()),
288            "ast_grep" => args
289                .get("pattern")
290                .and_then(|p| p.as_str())
291                .map(|p| format!("AST searching '{}'...", Self::truncate_command(p, 25)))
292                .unwrap_or_else(|| "AST searching...".to_string()),
293            "lsp" => args
294                .get("command")
295                .and_then(|c| c.as_str())
296                .map(|c| format!("Running LSP {}...", c))
297                .unwrap_or_else(|| "Running LSP...".to_string()),
298            _ => format!("Executing {}...", tool_name),
299        }
300    }
301
302    fn truncate_path(s: &str, max_len: usize) -> String {
303        if s.len() <= max_len {
304            s.to_string()
305        } else {
306            format!("...{}", &s[s.len().saturating_sub(max_len - 3)..])
307        }
308    }
309
310    fn truncate_command(s: &str, max_len: usize) -> String {
311        if s.len() <= max_len {
312            s.to_string()
313        } else {
314            format!("{}...", &s[..max_len.saturating_sub(3)])
315        }
316    }
317
318    /// Add a user message to the chat
319    pub fn add_user_message(&self, content: String) {
320        let msg = Message::user(content);
321        self.chat_view.lock().unwrap().add_message(msg);
322    }
323
324    /// Tick the spinner animation
325    pub fn tick_spinner(&self) {
326        self.spinner.lock().unwrap().tick();
327    }
328
329    /// Check if agent is busy
330    pub fn is_busy(&self) -> bool {
331        !matches!(self.state(), TuiState::Idle)
332    }
333
334    /// Get total input tokens for the session
335    pub fn total_input_tokens(&self) -> u64 {
336        self.total_input_tokens
337            .lock()
338            .map(|guard| *guard)
339            .unwrap_or(0)
340    }
341
342    /// Get total output tokens for the session
343    pub fn total_output_tokens(&self) -> u64 {
344        self.total_output_tokens
345            .lock()
346            .map(|guard| *guard)
347            .unwrap_or(0)
348    }
349
350    /// Get the current session ID
351    pub fn session_id(&self) -> String {
352        self.session_id
353            .lock()
354            .map(|guard| guard.clone())
355            .unwrap_or_else(|_| String::from("unknown"))
356    }
357
358    /// Save the current session
359    pub fn save_session(&self) -> Result<(), CliError> {
360        let session_id = self
361            .session_id
362            .lock()
363            .map(|guard| guard.clone())
364            .unwrap_or_else(|_| String::from("unknown"));
365
366        let messages = self
367            .messages
368            .lock()
369            .map(|guard| guard.clone())
370            .unwrap_or_default();
371
372        let input_tokens = self
373            .total_input_tokens
374            .lock()
375            .map(|guard| *guard)
376            .unwrap_or(0);
377
378        let output_tokens = self
379            .total_output_tokens
380            .lock()
381            .map(|guard| *guard)
382            .unwrap_or(0);
383
384        tracing::debug!(
385            "Saving session {} with {} messages, {} in tokens, {} out tokens",
386            session_id,
387            messages.len(),
388            input_tokens,
389            output_tokens
390        );
391
392        let session_manager = self.session_manager.lock().map_err(|e| {
393            CliError::ConfigError(format!("Failed to acquire session manager lock: {}", e))
394        })?;
395
396        session_manager.save_session(&session_id, &messages, input_tokens, output_tokens)?;
397        tracing::info!(
398            "✓ Session {} saved successfully ({} messages, {} in tokens, {} out tokens)",
399            session_id,
400            messages.len(),
401            input_tokens,
402            output_tokens
403        );
404        Ok(())
405    }
406}
407
408/// TUI Application for running the limit CLI in a terminal UI
409pub struct TuiApp {
410    tui_bridge: TuiBridge,
411    terminal: Terminal<CrosstermBackend<io::Stdout>>,
412    running: bool,
413    input_text: String,
414    cursor_pos: usize,
415    status_message: String,
416    status_is_error: bool,
417    cursor_blink_state: bool,
418    cursor_blink_timer: std::time::Instant,
419    /// Mouse selection state
420    mouse_selection_start: Option<(u16, u16)>,
421    /// Clipboard manager
422    clipboard: Option<ClipboardManager>,
423    /// File autocomplete state
424    file_autocomplete: Option<FileAutocompleteState>,
425    /// File finder instance
426    file_finder: FileFinder,
427}
428
429impl TuiApp {
430    /// Create a new TUI application
431    pub fn new(tui_bridge: TuiBridge) -> Result<Self, CliError> {
432        let backend = CrosstermBackend::new(io::stdout());
433        let terminal =
434            Terminal::new(backend).map_err(|e| CliError::IoError(io::Error::other(e)))?;
435
436        let session_id = tui_bridge.session_id();
437        tracing::info!("TUI started with session: {}", session_id);
438
439        let clipboard = match ClipboardManager::new() {
440            Ok(cb) => {
441                debug_log("✓ Clipboard initialized successfully");
442                tracing::info!("Clipboard initialized successfully");
443                Some(cb)
444            }
445            Err(e) => {
446                debug_log(&format!("✗ Clipboard initialization failed: {}", e));
447                tracing::warn!("Clipboard unavailable: {}", e);
448                None
449            }
450        };
451
452        // Initialize file finder with current directory
453        let working_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
454        let file_finder = FileFinder::new(working_dir);
455
456        Ok(Self {
457            tui_bridge,
458            terminal,
459            running: true,
460            input_text: String::new(),
461            cursor_pos: 0,
462            status_message: "Ready - Type a message and press Enter".to_string(),
463            status_is_error: false,
464            cursor_blink_state: true,
465            cursor_blink_timer: std::time::Instant::now(),
466            mouse_selection_start: None,
467            clipboard,
468            file_autocomplete: None,
469            file_finder,
470        })
471    }
472
473    /// Run the TUI event loop
474    pub fn run(&mut self) -> Result<(), CliError> {
475        // Enter alternate screen - creates a clean buffer for TUI
476        execute!(std::io::stdout(), EnterAlternateScreen)
477            .map_err(|e| CliError::IoError(io::Error::other(e)))?;
478
479        // Enable mouse capture for scroll support
480        execute!(std::io::stdout(), EnableMouseCapture)
481            .map_err(|e| CliError::IoError(io::Error::other(e)))?;
482
483        // Enable bracketed paste for multi-line paste support
484        execute!(std::io::stdout(), EnableBracketedPaste)
485            .map_err(|e| CliError::IoError(io::Error::other(e)))?;
486
487        crossterm::terminal::enable_raw_mode()
488            .map_err(|e| CliError::IoError(io::Error::other(e)))?;
489
490        // Guard to ensure cleanup on panic
491        struct AlternateScreenGuard;
492        impl Drop for AlternateScreenGuard {
493            fn drop(&mut self) {
494                let _ = crossterm::terminal::disable_raw_mode();
495                let _ = execute!(std::io::stdout(), DisableBracketedPaste);
496                let _ = execute!(std::io::stdout(), DisableMouseCapture);
497                let _ = execute!(std::io::stdout(), LeaveAlternateScreen);
498            }
499        }
500        let _guard = AlternateScreenGuard;
501
502        self.run_inner()
503    }
504
505    fn run_inner(&mut self) -> Result<(), CliError> {
506        while self.running {
507            // Process events from the agent
508            self.tui_bridge.process_events()?;
509
510            // Update spinner if in thinking state
511            if matches!(self.tui_bridge.state(), TuiState::Thinking) {
512                self.tui_bridge.tick_spinner();
513            }
514
515            // Update status based on state
516            self.update_status();
517
518            // Handle user input with poll timeout
519            if crossterm::event::poll(std::time::Duration::from_millis(100))
520                .map_err(|e| CliError::IoError(io::Error::other(e)))?
521            {
522                match event::read().map_err(|e| CliError::IoError(io::Error::other(e)))? {
523                    Event::Key(key) => {
524                        // Log ALL key events (not just Press) to debug modifier detection
525                        debug_log(&format!(
526                            "Event::Key - code={:?} mod={:?} kind={:?}",
527                            key.code, key.modifiers, key.kind
528                        ));
529
530                        if key.kind == KeyEventKind::Press {
531                            self.handle_key_event(key)?;
532                        }
533                    }
534                    Event::Mouse(mouse) => {
535                        match mouse.kind {
536                            MouseEventKind::Down(MouseButton::Left) => {
537                                debug_log(&format!(
538                                    "MouseDown at ({}, {})",
539                                    mouse.column, mouse.row
540                                ));
541                                self.mouse_selection_start = Some((mouse.column, mouse.row));
542                                // Map screen position to message/offset and start selection
543                                let chat = self.tui_bridge.chat_view().lock().unwrap();
544                                debug_log(&format!(
545                                    "  render_positions count: {}",
546                                    chat.render_position_count()
547                                ));
548                                if let Some((msg_idx, char_offset)) =
549                                    chat.screen_to_text_pos(mouse.column, mouse.row)
550                                {
551                                    debug_log(&format!(
552                                        "  -> Starting selection at msg={}, offset={}",
553                                        msg_idx, char_offset
554                                    ));
555                                    drop(chat);
556                                    self.tui_bridge
557                                        .chat_view()
558                                        .lock()
559                                        .unwrap()
560                                        .start_selection(msg_idx, char_offset);
561                                } else {
562                                    debug_log("  -> No match, clearing selection");
563                                    drop(chat);
564                                    self.tui_bridge
565                                        .chat_view()
566                                        .lock()
567                                        .unwrap()
568                                        .clear_selection();
569                                }
570                            }
571                            MouseEventKind::Drag(MouseButton::Left) => {
572                                if self.mouse_selection_start.is_some() {
573                                    // Extend selection to current position
574                                    let chat = self.tui_bridge.chat_view().lock().unwrap();
575                                    if let Some((msg_idx, char_offset)) =
576                                        chat.screen_to_text_pos(mouse.column, mouse.row)
577                                    {
578                                        drop(chat);
579                                        self.tui_bridge
580                                            .chat_view()
581                                            .lock()
582                                            .unwrap()
583                                            .extend_selection(msg_idx, char_offset);
584                                    }
585                                }
586                            }
587                            MouseEventKind::Up(MouseButton::Left) => {
588                                self.mouse_selection_start = None;
589                            }
590                            MouseEventKind::ScrollUp => {
591                                let mut chat = self.tui_bridge.chat_view().lock().unwrap();
592                                chat.scroll_up();
593                            }
594                            MouseEventKind::ScrollDown => {
595                                let mut chat = self.tui_bridge.chat_view().lock().unwrap();
596                                chat.scroll_down();
597                            }
598                            _ => {}
599                        }
600                    }
601                    Event::Paste(pasted) => {
602                        if !self.tui_bridge.is_busy() {
603                            self.insert_paste(&pasted);
604                        }
605                    }
606                    _ => {}
607                }
608            } else {
609                // No key event - tick cursor blink
610                self.tick_cursor_blink();
611            }
612
613            // Draw the TUI
614            self.draw()?;
615        }
616
617        // Save session before exiting
618        if let Err(e) = self.tui_bridge.save_session() {
619            tracing::error!("Failed to save session: {}", e);
620        }
621
622        Ok(())
623    }
624
625    fn update_status(&mut self) {
626        let session_id = self.tui_bridge.session_id();
627        let has_activity = self
628            .tui_bridge
629            .activity_feed()
630            .lock()
631            .unwrap()
632            .has_in_progress();
633
634        match self.tui_bridge.state() {
635            TuiState::Idle => {
636                if has_activity {
637                    // Show spinner when there are in-progress activities
638                    let spinner = self.tui_bridge.spinner().lock().unwrap();
639                    self.status_message = format!("{} Processing...", spinner.current_frame());
640                } else {
641                    self.status_message = format!(
642                        "Ready | Session: {}",
643                        session_id.chars().take(8).collect::<String>()
644                    );
645                }
646                self.status_is_error = false;
647            }
648            TuiState::Thinking => {
649                let spinner = self.tui_bridge.spinner().lock().unwrap();
650                self.status_message = format!("{} Thinking...", spinner.current_frame());
651                self.status_is_error = false;
652            }
653        }
654    }
655
656    /// Insert pasted text at cursor position without submitting
657    fn insert_paste(&mut self, text: &str) {
658        // Enforce size limit to prevent memory issues
659        let text = if text.len() > MAX_PASTE_SIZE {
660            self.status_message = "Paste truncated (too large)".to_string();
661            self.status_is_error = true;
662            // Find valid UTF-8 boundary at approximately MAX_PASTE_SIZE
663            &text[..text
664                .char_indices()
665                .nth(MAX_PASTE_SIZE)
666                .map(|(i, _)| i)
667                .unwrap_or(text.len())]
668        } else {
669            text
670        };
671
672        // Normalize newlines (some terminals convert \n to \r)
673        let normalized = text.replace("\r", "\n");
674        self.input_text.insert_str(self.cursor_pos, &normalized);
675        self.cursor_pos += normalized.len();
676    }
677
678    /// Check if the current key event is a copy/paste shortcut
679    /// Returns true for Ctrl+C/V on Linux/Windows, Cmd+C/V on macOS
680    /// Note: Some macOS terminals report Cmd as CONTROL instead of SUPER
681    fn is_copy_paste_modifier(&self, key: &KeyEvent, char: char) -> bool {
682        #[cfg(target_os = "macos")]
683        {
684            // Accept both SUPER and CONTROL on macOS since terminal emulators vary
685            let has_super = key.modifiers.contains(KeyModifiers::SUPER);
686            let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
687            let result = key.code == KeyCode::Char(char) && (has_super || has_ctrl);
688            debug_log(&format!("is_copy_paste_modifier('{}') macOS: code={:?}, mod={:?}, super={}, ctrl={}, result={}", 
689                char, key.code, key.modifiers, has_super, has_ctrl, result));
690            result
691        }
692        #[cfg(not(target_os = "macos"))]
693        {
694            let result =
695                key.code == KeyCode::Char(char) && key.modifiers.contains(KeyModifiers::CONTROL);
696            debug_log(&format!(
697                "is_copy_paste_modifier('{}') non-macOS: code={:?}, mod={:?}, ctrl={:?}, result={}",
698                char,
699                key.code,
700                key.modifiers,
701                KeyModifiers::CONTROL,
702                result
703            ));
704            result
705        }
706    }
707
708    fn tick_cursor_blink(&mut self) {
709        // Blink every 500ms for standard terminal cursor behavior
710        if self.cursor_blink_timer.elapsed().as_millis() > 500 {
711            self.cursor_blink_state = !self.cursor_blink_state;
712            self.cursor_blink_timer = std::time::Instant::now();
713        }
714    }
715
716    fn handle_key_event(&mut self, key: KeyEvent) -> Result<(), CliError> {
717        // Direct file logging (always works)
718        debug_log(&format!(
719            "handle_key_event: code={:?} mod={:?} kind={:?}",
720            key.code, key.modifiers, key.kind
721        ));
722
723        // Special log for 'c' and 'v' keys to debug copy/paste
724        if matches!(key.code, KeyCode::Char('c') | KeyCode::Char('v')) {
725            debug_log(&format!(
726                ">>> SPECIAL: '{}' key detected with modifiers: {:?} (SUPER={:?}, CONTROL={:?})",
727                if matches!(key.code, KeyCode::Char('c')) {
728                    'c'
729                } else {
730                    'v'
731                },
732                key.modifiers,
733                key.modifiers.contains(KeyModifiers::SUPER),
734                key.modifiers.contains(KeyModifiers::CONTROL)
735            ));
736        }
737
738        // Copy selection to clipboard (Ctrl/Cmd+C)
739        if self.is_copy_paste_modifier(&key, 'c') {
740            debug_log("✓ Copy shortcut CONFIRMED - processing...");
741            let mut chat = self.tui_bridge.chat_view().lock().unwrap();
742            let has_selection = chat.has_selection();
743            debug_log(&format!("has_selection={}", has_selection));
744
745            if has_selection {
746                if let Some(selected) = chat.get_selected_text() {
747                    debug_log(&format!("Selected text length={}", selected.len()));
748                    if !selected.is_empty() {
749                        if let Some(ref clipboard) = self.clipboard {
750                            debug_log("Attempting to copy to clipboard...");
751                            match clipboard.set_text(&selected) {
752                                Ok(()) => {
753                                    debug_log("✓ Clipboard copy successful");
754                                    self.status_message = "Copied to clipboard".to_string();
755                                    self.status_is_error = false;
756                                }
757                                Err(e) => {
758                                    debug_log(&format!("✗ Clipboard copy failed: {}", e));
759                                    self.status_message = format!("Clipboard error: {}", e);
760                                    self.status_is_error = true;
761                                }
762                            }
763                        } else {
764                            debug_log("✗ Clipboard not available (None)");
765                            self.status_message = "Clipboard not available".to_string();
766                            self.status_is_error = true;
767                        }
768                    } else {
769                        debug_log("Selected text is empty");
770                    }
771                    chat.clear_selection();
772                } else {
773                    debug_log("get_selected_text() returned None");
774                }
775                return Ok(());
776            }
777
778            // No selection - do nothing (Ctrl+C is only for copying text)
779            debug_log("Ctrl/Cmd+C with no selection - ignoring");
780            return Ok(());
781        }
782
783        // Paste from clipboard (Ctrl/Cmd+V)
784        if self.is_copy_paste_modifier(&key, 'v') && !self.tui_bridge.is_busy() {
785            debug_log("✓ Paste shortcut CONFIRMED - processing...");
786            if let Some(ref clipboard) = self.clipboard {
787                debug_log("Attempting to read from clipboard...");
788                match clipboard.get_text() {
789                    Ok(text) if !text.is_empty() => {
790                        debug_log(&format!("Read {} chars from clipboard", text.len()));
791                        self.insert_paste(&text);
792                    }
793                    Ok(_) => {
794                        debug_log("Clipboard is empty");
795                    } // Empty clipboard
796                    Err(e) => {
797                        debug_log(&format!("✗ Failed to read clipboard: {}", e));
798                        self.status_message = format!("Could not read clipboard: {}", e);
799                        self.status_is_error = true;
800                    }
801                }
802            } else {
803                debug_log("✗ Clipboard not available (None)");
804                self.status_message = "Clipboard not available".to_string();
805                self.status_is_error = true;
806            }
807            return Ok(());
808        }
809
810        // Handle autocomplete navigation FIRST (before general scrolling)
811        if let Some(ref mut ac) = self.file_autocomplete {
812            if ac.is_active {
813                match key.code {
814                    KeyCode::Up => {
815                        if ac.selected_index > 0 {
816                            ac.selected_index -= 1;
817                        }
818                        return Ok(());
819                    }
820                    KeyCode::Down => {
821                        if ac.selected_index + 1 < ac.matches.len() {
822                            ac.selected_index += 1;
823                        }
824                        return Ok(());
825                    }
826                    KeyCode::Enter | KeyCode::Tab => {
827                        // Accept selected completion
828                        self.accept_file_completion();
829                        return Ok(());
830                    }
831                    KeyCode::Esc => {
832                        // Cancel autocomplete
833                        self.file_autocomplete = None;
834                        return Ok(());
835                    }
836                    _ => {}
837                }
838            }
839        }
840
841        // Allow scrolling even when agent is busy
842        // Calculate actual viewport height dynamically
843        let term_height = self.terminal.size().map(|s| s.height).unwrap_or(24);
844        let viewport_height = term_height
845            .saturating_sub(1) // status bar - input area (6 lines) - borders (~2)
846            .saturating_sub(7); // status (1) + input (6) + top/bottom borders (2) = 9
847        match key.code {
848            KeyCode::PageUp => {
849                let mut chat = self.tui_bridge.chat_view().lock().unwrap();
850                chat.scroll_page_up(viewport_height);
851                return Ok(());
852            }
853            KeyCode::PageDown => {
854                let mut chat = self.tui_bridge.chat_view().lock().unwrap();
855                chat.scroll_page_down(viewport_height);
856                return Ok(());
857            }
858            KeyCode::Up => {
859                let mut chat = self.tui_bridge.chat_view().lock().unwrap();
860                chat.scroll_up();
861                return Ok(());
862            }
863            KeyCode::Down => {
864                // Otherwise scroll chat
865                let mut chat = self.tui_bridge.chat_view().lock().unwrap();
866                chat.scroll_down();
867                return Ok(());
868            }
869            _ => {}
870        }
871
872        // Don't accept input while agent is busy
873        if self.tui_bridge.is_busy() {
874            debug_log("Agent busy, ignoring");
875            return Ok(());
876        }
877
878        // Handle backspace - try multiple detection methods
879        if self.handle_backspace(&key) {
880            debug_log(&format!("Backspace handled, input: {:?}", self.input_text));
881            return Ok(());
882        }
883
884        match key.code {
885            KeyCode::Delete => {
886                if self.cursor_pos < self.input_text.len() {
887                    let next_pos = self.next_char_pos();
888                    self.input_text.drain(self.cursor_pos..next_pos);
889                    debug_log(&format!("Delete: input now: {:?}", self.input_text));
890                }
891            }
892            KeyCode::Left => {
893                if self.cursor_pos > 0 {
894                    self.cursor_pos = self.prev_char_pos();
895                }
896            }
897            KeyCode::Right => {
898                if self.cursor_pos < self.input_text.len() {
899                    self.cursor_pos = self.next_char_pos();
900                }
901            }
902            KeyCode::Home => {
903                self.cursor_pos = 0;
904            }
905            KeyCode::End => {
906                self.cursor_pos = self.input_text.len();
907            }
908            KeyCode::Enter => {
909                // If autocomplete is active, it's already handled above
910                self.handle_enter()?;
911            }
912            KeyCode::Esc => {
913                // If autocomplete is active, cancel it
914                if self.file_autocomplete.is_some() {
915                    self.file_autocomplete = None;
916                } else {
917                    debug_log("Esc pressed, exiting");
918                    self.running = false;
919                }
920            }
921            // Regular character input (including UTF-8)
922            KeyCode::Char(c)
923                if key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT =>
924            {
925                // Check if we're triggering autocomplete with @
926                if c == '@' {
927                    // Insert @ character
928                    self.input_text.insert(self.cursor_pos, '@');
929                    self.cursor_pos += 1;
930
931                    // Activate autocomplete
932                    self.activate_file_autocomplete();
933                } else {
934                    // Check if autocomplete is active
935                    let is_autocomplete_active = self
936                        .file_autocomplete
937                        .as_ref()
938                        .map(|ac| ac.is_active)
939                        .unwrap_or(false);
940
941                    if is_autocomplete_active {
942                        // Clone query to avoid borrow issues
943                        let query = self
944                            .file_autocomplete
945                            .as_ref()
946                            .map(|ac| {
947                                let mut q = ac.query.clone();
948                                q.push(c);
949                                q
950                            })
951                            .unwrap_or_default();
952
953                        // Get matches
954                        let matches = self.get_file_matches(&query);
955
956                        // Update autocomplete state
957                        if let Some(ref mut ac) = self.file_autocomplete {
958                            ac.query.push(c);
959                            ac.matches = matches;
960                            ac.selected_index = 0;
961                        }
962
963                        // Also insert the character in input
964                        self.input_text.insert(self.cursor_pos, c);
965                        self.cursor_pos += c.len_utf8();
966                    } else {
967                        // Normal character insertion
968                        self.input_text.insert(self.cursor_pos, c);
969                        self.cursor_pos += c.len_utf8();
970                    }
971                }
972            }
973            _ => {
974                // Ignore other keys
975            }
976        }
977
978        Ok(())
979    }
980
981    /// Handle backspace with multiple detection methods
982    fn handle_backspace(&mut self, key: &KeyEvent) -> bool {
983        // Method 1: Standard Backspace keycode
984        if key.code == KeyCode::Backspace {
985            debug_log("Backspace detected via KeyCode::Backspace");
986            self.delete_char_before_cursor();
987            return true;
988        }
989
990        // Method 2: Ctrl+H (common backspace mapping)
991        if key.code == KeyCode::Char('h') && key.modifiers == KeyModifiers::CONTROL {
992            debug_log("Backspace detected via Ctrl+H");
993            self.delete_char_before_cursor();
994            return true;
995        }
996
997        // Method 3: Check for DEL (127) or BS (8) characters
998        if let KeyCode::Char(c) = key.code {
999            if c == '\x7f' || c == '\x08' {
1000                debug_log(&format!("Backspace detected via char code: {}", c as u8));
1001                self.delete_char_before_cursor();
1002                return true;
1003            }
1004        }
1005
1006        false
1007    }
1008
1009    fn delete_char_before_cursor(&mut self) {
1010        debug_log(&format!(
1011            "delete_char: cursor={}, len={}, input={:?}",
1012            self.cursor_pos,
1013            self.input_text.len(),
1014            self.input_text
1015        ));
1016
1017        // If autocomplete is active, handle backspace specially
1018        let should_close_autocomplete = if let Some(ref ac) = self.file_autocomplete {
1019            if ac.is_active {
1020                // Check if query is empty
1021                ac.query.is_empty()
1022            } else {
1023                false
1024            }
1025        } else {
1026            false
1027        };
1028
1029        if should_close_autocomplete {
1030            // Need to delete the @ from input
1031            if self.cursor_pos > 0 {
1032                let prev_pos = self.prev_char_pos();
1033                if &self.input_text[prev_pos..self.cursor_pos] == "@" {
1034                    self.input_text.drain(prev_pos..self.cursor_pos);
1035                    self.cursor_pos = prev_pos;
1036                    self.file_autocomplete = None;
1037                    return;
1038                }
1039            }
1040        }
1041
1042        // Update autocomplete query if active
1043        if self
1044            .file_autocomplete
1045            .as_ref()
1046            .map(|ac| ac.is_active && !ac.query.is_empty())
1047            .unwrap_or(false)
1048        {
1049            let new_query = self
1050                .file_autocomplete
1051                .as_ref()
1052                .map(|ac| {
1053                    let mut q = ac.query.clone();
1054                    q.pop();
1055                    q
1056                })
1057                .unwrap_or_default();
1058
1059            let matches = self.get_file_matches(&new_query);
1060
1061            if let Some(ref mut ac) = self.file_autocomplete {
1062                ac.query.pop();
1063                ac.matches = matches;
1064                ac.selected_index = 0;
1065            }
1066        }
1067
1068        if self.cursor_pos > 0 {
1069            let prev_pos = self.prev_char_pos();
1070            debug_log(&format!("draining {}..{}", prev_pos, self.cursor_pos));
1071            self.input_text.drain(prev_pos..self.cursor_pos);
1072            self.cursor_pos = prev_pos;
1073            debug_log(&format!(
1074                "after delete: cursor={}, input={:?}",
1075                self.cursor_pos, self.input_text
1076            ));
1077        } else {
1078            debug_log("cursor at 0, nothing to delete");
1079        }
1080    }
1081
1082    /// Activate file autocomplete
1083    fn activate_file_autocomplete(&mut self) {
1084        let matches = self.get_file_matches("");
1085
1086        self.file_autocomplete = Some(FileAutocompleteState {
1087            is_active: true,
1088            query: String::new(),
1089            trigger_pos: self.cursor_pos - 1, // Position of @
1090            matches,
1091            selected_index: 0,
1092        });
1093
1094        debug_log(&format!(
1095            "Activated autocomplete at pos {}",
1096            self.cursor_pos - 1
1097        ));
1098    }
1099
1100    /// Get file matches for autocomplete
1101    fn get_file_matches(&mut self, query: &str) -> Vec<FileMatchData> {
1102        let files = self.file_finder.scan_files().clone();
1103        let matches = self.file_finder.filter_files(&files, query);
1104
1105        matches
1106            .into_iter()
1107            .map(|m| FileMatchData {
1108                path: m.path.to_string_lossy().to_string(),
1109                is_dir: m.is_dir,
1110            })
1111            .collect()
1112    }
1113
1114    /// Accept selected file completion
1115    fn accept_file_completion(&mut self) {
1116        if let Some(ref ac) = self.file_autocomplete {
1117            if let Some(selected) = ac.matches.get(ac.selected_index) {
1118                // Replace @query with @path
1119                let end_pos = self.cursor_pos;
1120
1121                // Calculate how much to remove (from @ to current cursor)
1122                let remove_start = ac.trigger_pos;
1123
1124                // Remove the query part (keep the @)
1125                self.input_text.drain(remove_start + 1..end_pos);
1126                self.cursor_pos = remove_start + 1;
1127
1128                // Insert the selected path
1129                self.input_text.insert_str(self.cursor_pos, &selected.path);
1130                self.cursor_pos += selected.path.len();
1131
1132                // Add space after completion for better UX
1133                self.input_text.insert(self.cursor_pos, ' ');
1134                self.cursor_pos += 1;
1135
1136                debug_log(&format!(
1137                    "Accepted completion: {} -> input now: {:?}",
1138                    selected.path, self.input_text
1139                ));
1140            }
1141        }
1142
1143        // Close autocomplete
1144        self.file_autocomplete = None;
1145    }
1146
1147    fn handle_enter(&mut self) -> Result<(), CliError> {
1148        let text = self.input_text.trim().to_string();
1149
1150        // Clear input FIRST for immediate visual feedback
1151        self.input_text.clear();
1152        self.cursor_pos = 0;
1153
1154        if text.is_empty() {
1155            return Ok(());
1156        }
1157
1158        tracing::info!("Enter pressed with text: {:?}", text);
1159
1160        // Handle commands locally (no LLM)
1161        let text_lower = text.to_lowercase();
1162        if text_lower == "/exit"
1163            || text_lower == "/quit"
1164            || text_lower == "exit"
1165            || text_lower == "quit"
1166        {
1167            tracing::info!("Exit command detected, exiting");
1168            self.running = false;
1169            return Ok(());
1170        }
1171
1172        if text_lower == "/clear" || text_lower == "clear" {
1173            tracing::info!("Clear command detected");
1174            self.tui_bridge.chat_view().lock().unwrap().clear();
1175            return Ok(());
1176        }
1177
1178        if text_lower == "/help" || text_lower == "help" {
1179            tracing::info!("Help command detected");
1180            let help_msg = Message::system(
1181                "Available commands:\n\
1182                 /help  - Show this help message\n\
1183                 /clear - Clear chat history\n\
1184                 /exit  - Exit the application\n\
1185                 /quit  - Exit the application\n\
1186                 /session list  - List all sessions\n\
1187                 /session new   - Create a new session\n\
1188                 /session load  <id> - Load a session by ID\n\
1189                 \n\
1190                 Page Up/Down - Scroll chat history"
1191                    .to_string(),
1192            );
1193            self.tui_bridge
1194                .chat_view()
1195                .lock()
1196                .unwrap()
1197                .add_message(help_msg);
1198            return Ok(());
1199        }
1200
1201        // Handle session commands
1202        if text_lower.starts_with("/session ") {
1203            let session_cmd = text.strip_prefix("/session ").unwrap();
1204            if session_cmd.trim() == "list" {
1205                self.handle_session_list()?;
1206                return Ok(());
1207            } else if session_cmd.trim() == "new" {
1208                self.handle_session_new()?;
1209                return Ok(());
1210            } else if session_cmd.starts_with("load ") {
1211                let session_id = session_cmd.strip_prefix("load ").unwrap().trim();
1212                self.handle_session_load(session_id)?;
1213                return Ok(());
1214            } else {
1215                let error_msg = Message::system(
1216                    "Usage: /session list, /session new, /session load <id>".to_string(),
1217                );
1218                self.tui_bridge
1219                    .chat_view()
1220                    .lock()
1221                    .unwrap()
1222                    .add_message(error_msg);
1223                return Ok(());
1224            }
1225        }
1226
1227        // Add user message to chat (for display)
1228        self.tui_bridge.add_user_message(text.clone());
1229
1230        // Clone Arcs for the spawned thread
1231        let messages = self.tui_bridge.messages.clone();
1232        let agent_bridge = self.tui_bridge.agent_bridge_arc();
1233        let session_manager = self.tui_bridge.session_manager.clone();
1234        let session_id = self.tui_bridge.session_id();
1235        let total_input_tokens = self.tui_bridge.total_input_tokens.clone();
1236        let total_output_tokens = self.tui_bridge.total_output_tokens.clone();
1237
1238        tracing::debug!("Spawning LLM processing thread");
1239
1240        // Spawn a thread to process the message without blocking the UI
1241        std::thread::spawn(move || {
1242            // Create a new tokio runtime for this thread
1243            let rt = tokio::runtime::Runtime::new().unwrap();
1244
1245            // Safe: we're in a dedicated thread, this won't cause issues
1246            #[allow(clippy::await_holding_lock)]
1247            rt.block_on(async {
1248                let mut messages_guard = messages.lock().unwrap();
1249                let mut bridge = agent_bridge.lock().unwrap();
1250
1251                match bridge.process_message(&text, &mut messages_guard).await {
1252                    Ok(_response) => {
1253                        // Response already displayed via streaming (ContentChunk events)
1254                        // No need to add_message again - would cause duplication
1255                        // Auto-save session after successful response
1256                        let msgs = messages_guard.clone();
1257                        let input_tokens = *total_input_tokens.lock().unwrap();
1258                        let output_tokens = *total_output_tokens.lock().unwrap();
1259
1260                        if let Err(e) = session_manager.lock().unwrap().save_session(
1261                            &session_id,
1262                            &msgs,
1263                            input_tokens,
1264                            output_tokens,
1265                        ) {
1266                            tracing::error!("✗ Failed to auto-save session {}: {}", session_id, e);
1267                        } else {
1268                            tracing::info!(
1269                                "✓ Session {} auto-saved ({} messages, {} in, {} out tokens)",
1270                                session_id,
1271                                msgs.len(),
1272                                input_tokens,
1273                                output_tokens
1274                            );
1275                        }
1276                    }
1277                    Err(e) => {
1278                        tracing::error!("LLM error: {}", e);
1279                    }
1280                }
1281            });
1282        });
1283
1284        Ok(())
1285    }
1286
1287    /// Handle /session list command
1288    fn handle_session_list(&self) -> Result<(), CliError> {
1289        tracing::info!("Session list command detected");
1290        let session_manager = self.tui_bridge.session_manager.lock().unwrap();
1291        let current_session_id = self.tui_bridge.session_id();
1292
1293        match session_manager.list_sessions() {
1294            Ok(sessions) => {
1295                if sessions.is_empty() {
1296                    let msg = Message::system("No sessions found.".to_string());
1297                    self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1298                } else {
1299                    let mut output = vec!["Sessions (most recent first):".to_string()];
1300                    for (i, session) in sessions.iter().enumerate() {
1301                        let current = if session.id == current_session_id {
1302                            " (current)"
1303                        } else {
1304                            ""
1305                        };
1306                        let short_id = if session.id.len() > 8 {
1307                            &session.id[..8]
1308                        } else {
1309                            &session.id
1310                        };
1311                        output.push(format!(
1312                            "  {}. {}{} - {} messages, {} in tokens, {} out tokens",
1313                            i + 1,
1314                            short_id,
1315                            current,
1316                            session.message_count,
1317                            session.total_input_tokens,
1318                            session.total_output_tokens
1319                        ));
1320                    }
1321                    let msg = Message::system(output.join("\n"));
1322                    self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1323                }
1324            }
1325            Err(e) => {
1326                let msg = Message::system(format!("Error listing sessions: {}", e));
1327                self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1328            }
1329        }
1330        Ok(())
1331    }
1332
1333    /// Handle /session new command
1334    fn handle_session_new(&mut self) -> Result<(), CliError> {
1335        tracing::info!("Session new command detected");
1336
1337        // Save current session (release lock before proceeding)
1338        let save_result = self.tui_bridge.save_session();
1339        if let Err(e) = &save_result {
1340            tracing::error!("Failed to save current session: {}", e);
1341            let msg = Message::system(format!("⚠ Warning: Failed to save current session: {}", e));
1342            if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1343                chat.add_message(msg);
1344            }
1345        }
1346
1347        // Create new session (separate lock scope)
1348        let new_session_id = {
1349            let session_manager = self.tui_bridge.session_manager.lock().map_err(|e| {
1350                CliError::ConfigError(format!("Failed to acquire session manager lock: {}", e))
1351            })?;
1352
1353            session_manager
1354                .create_new_session()
1355                .map_err(|e| CliError::ConfigError(format!("Failed to create session: {}", e)))?
1356        };
1357
1358        let old_session_id = self.tui_bridge.session_id();
1359
1360        // Update session ID
1361        if let Ok(mut id_guard) = self.tui_bridge.session_id.try_lock() {
1362            *id_guard = new_session_id.clone();
1363        }
1364
1365        // Clear messages and reset token counts (separate locks)
1366        if let Ok(mut messages_guard) = self.tui_bridge.messages.try_lock() {
1367            messages_guard.clear();
1368        }
1369        if let Ok(mut input_guard) = self.tui_bridge.total_input_tokens.try_lock() {
1370            *input_guard = 0;
1371        }
1372        if let Ok(mut output_guard) = self.tui_bridge.total_output_tokens.try_lock() {
1373            *output_guard = 0;
1374        }
1375
1376        tracing::info!(
1377            "Created new session: {} (old: {})",
1378            new_session_id,
1379            old_session_id
1380        );
1381
1382        // Add system message
1383        let session_short_id = if new_session_id.len() > 8 {
1384            &new_session_id[new_session_id.len().saturating_sub(8)..]
1385        } else {
1386            &new_session_id
1387        };
1388        let msg = Message::system(format!("🆕 New session created: {}", session_short_id));
1389
1390        if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1391            chat.add_message(msg);
1392        }
1393
1394        Ok(())
1395    }
1396
1397    /// Handle /session load <id> command
1398    fn handle_session_load(&mut self, session_id: &str) -> Result<(), CliError> {
1399        tracing::info!("Session load command detected for session: {}", session_id);
1400
1401        // Save current session first (release lock before proceeding)
1402        let save_result = self.tui_bridge.save_session();
1403        if let Err(e) = &save_result {
1404            tracing::error!("Failed to save current session: {}", e);
1405            let msg = Message::system(format!("⚠ Warning: Failed to save current session: {}", e));
1406            if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1407                chat.add_message(msg);
1408            }
1409        }
1410
1411        // Find session ID from partial match (acquire locks separately to avoid deadlock)
1412        let (full_session_id, session_info, messages) = {
1413            let session_manager = self.tui_bridge.session_manager.lock().map_err(|e| {
1414                CliError::ConfigError(format!("Failed to acquire session manager lock: {}", e))
1415            })?;
1416
1417            let sessions = session_manager
1418                .list_sessions()
1419                .map_err(|e| CliError::ConfigError(format!("Failed to list sessions: {}", e)))?;
1420
1421            let matched_session = if session_id.len() >= 8 {
1422                // Try exact match first
1423                sessions
1424                    .iter()
1425                    .find(|s| s.id == session_id)
1426                    // Then try prefix match
1427                    .or_else(|| sessions.iter().find(|s| s.id.starts_with(session_id)))
1428            } else {
1429                // Try prefix match for short IDs
1430                sessions.iter().find(|s| s.id.starts_with(session_id))
1431            };
1432
1433            match matched_session {
1434                Some(info) => {
1435                    let full_id = info.id.clone();
1436                    // Load messages within the same lock scope
1437                    let msgs = session_manager.load_session(&full_id).map_err(|e| {
1438                        CliError::ConfigError(format!("Failed to load session {}: {}", full_id, e))
1439                    })?;
1440                    (full_id, info.clone(), msgs)
1441                }
1442                None => {
1443                    let msg = Message::system(format!("❌ Session not found: {}", session_id));
1444                    if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1445                        chat.add_message(msg);
1446                    }
1447                    return Ok(());
1448                }
1449            }
1450        };
1451
1452        // Update session ID and token counts (separate locks)
1453        if let Ok(mut id_guard) = self.tui_bridge.session_id.try_lock() {
1454            *id_guard = full_session_id.clone();
1455        }
1456        if let Ok(mut input_guard) = self.tui_bridge.total_input_tokens.try_lock() {
1457            *input_guard = session_info.total_input_tokens;
1458        }
1459        if let Ok(mut output_guard) = self.tui_bridge.total_output_tokens.try_lock() {
1460            *output_guard = session_info.total_output_tokens;
1461        }
1462
1463        // Update messages in TUI bridge
1464        if let Ok(mut messages_guard) = self.tui_bridge.messages.try_lock() {
1465            *messages_guard = messages.clone();
1466        }
1467
1468        // Clear chat view and reload messages
1469        if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1470            chat.clear();
1471
1472            // Reload messages into chat view (no additional locks needed)
1473            for msg in &messages {
1474                match msg.role {
1475                    limit_llm::Role::User => {
1476                        let content = msg.content.as_deref().unwrap_or("");
1477                        let chat_msg = Message::user(content.to_string());
1478                        chat.add_message(chat_msg);
1479                    }
1480                    limit_llm::Role::Assistant => {
1481                        let content = msg.content.as_deref().unwrap_or("");
1482                        let chat_msg = Message::assistant(content.to_string());
1483                        chat.add_message(chat_msg);
1484                    }
1485                    _ => {}
1486                }
1487            }
1488
1489            tracing::info!(
1490                "Loaded session: {} ({} messages)",
1491                full_session_id,
1492                messages.len()
1493            );
1494
1495            // Add system message
1496            let session_short_id = if full_session_id.len() > 8 {
1497                &full_session_id[full_session_id.len().saturating_sub(8)..]
1498            } else {
1499                &full_session_id
1500            };
1501            let msg = Message::system(format!(
1502                "📂 Loaded session: {} ({} messages, {} in tokens, {} out tokens)",
1503                session_short_id,
1504                messages.len(),
1505                session_info.total_input_tokens,
1506                session_info.total_output_tokens
1507            ));
1508            chat.add_message(msg);
1509        }
1510
1511        Ok(())
1512    }
1513
1514    fn prev_char_pos(&self) -> usize {
1515        if self.cursor_pos == 0 {
1516            return 0;
1517        }
1518        // Start ONE position before cursor, then find char boundary
1519        let mut pos = self.cursor_pos - 1;
1520        while pos > 0 && !self.input_text.is_char_boundary(pos) {
1521            pos -= 1;
1522        }
1523        pos
1524    }
1525
1526    fn next_char_pos(&self) -> usize {
1527        if self.cursor_pos >= self.input_text.len() {
1528            return self.input_text.len();
1529        }
1530        // Start ONE position after cursor, then find char boundary
1531        let mut pos = self.cursor_pos + 1;
1532        while pos < self.input_text.len() && !self.input_text.is_char_boundary(pos) {
1533            pos += 1;
1534        }
1535        pos
1536    }
1537
1538    fn draw(&mut self) -> Result<(), CliError> {
1539        let chat_view = self.tui_bridge.chat_view().clone();
1540        let state = self.tui_bridge.state();
1541        let input_text = self.input_text.clone();
1542        let cursor_pos = self.cursor_pos;
1543        let status_message = self.status_message.clone();
1544        let status_is_error = self.status_is_error;
1545        let cursor_blink_state = self.cursor_blink_state;
1546        let tui_bridge = &self.tui_bridge;
1547        let file_autocomplete = self.file_autocomplete.clone();
1548
1549        self.terminal
1550            .draw(|f| {
1551                Self::draw_ui(
1552                    f,
1553                    &chat_view,
1554                    state,
1555                    &input_text,
1556                    cursor_pos,
1557                    &status_message,
1558                    status_is_error,
1559                    cursor_blink_state,
1560                    tui_bridge,
1561                    &file_autocomplete,
1562                );
1563            })
1564            .map_err(|e| CliError::IoError(io::Error::other(e)))?;
1565
1566        Ok(())
1567    }
1568
1569    /// Draw the TUI interface
1570    #[allow(clippy::too_many_arguments)]
1571    fn draw_ui(
1572        f: &mut Frame,
1573        chat_view: &Arc<Mutex<ChatView>>,
1574        _state: TuiState,
1575        input_text: &str,
1576        cursor_pos: usize,
1577        status_message: &str,
1578        status_is_error: bool,
1579        cursor_blink_state: bool,
1580        tui_bridge: &TuiBridge,
1581        file_autocomplete: &Option<FileAutocompleteState>,
1582    ) {
1583        let size = f.area();
1584
1585        // Check if we have activities to show
1586        let activity_count = tui_bridge.activity_feed().lock().unwrap().len();
1587        let activity_height = if activity_count > 0 {
1588            (activity_count as u16).min(3) // Max 3 lines for activity feed
1589        } else {
1590            0
1591        };
1592
1593        // Build constraints based on whether we have activities
1594        let constraints: Vec<Constraint> = vec![Constraint::Percentage(90)]; // Chat view
1595        let mut constraints = constraints;
1596        if activity_height > 0 {
1597            constraints.push(Constraint::Length(activity_height)); // Activity feed
1598        }
1599        constraints.push(Constraint::Length(1)); // Status bar
1600        constraints.push(Constraint::Length(6)); // Input area
1601
1602        // Split the screen
1603        let chunks = Layout::default()
1604            .direction(Direction::Vertical)
1605            .constraints(constraints.as_slice())
1606            .split(size);
1607
1608        let mut chunk_idx = 0;
1609
1610        // Draw chat view with border
1611        {
1612            let chat = chat_view.lock().unwrap();
1613            let total_input = tui_bridge.total_input_tokens();
1614            let total_output = tui_bridge.total_output_tokens();
1615            let title = format!(" Chat (↑{} ↓{}) ", total_input, total_output);
1616            let chat_block = Block::default()
1617                .borders(Borders::ALL)
1618                .title(title)
1619                .title_style(
1620                    Style::default()
1621                        .fg(Color::Cyan)
1622                        .add_modifier(Modifier::BOLD),
1623                );
1624            f.render_widget(&*chat, chat_block.inner(chunks[chunk_idx]));
1625            f.render_widget(chat_block, chunks[chunk_idx]);
1626            chunk_idx += 1;
1627        }
1628
1629        // Draw activity feed if present
1630        if activity_height > 0 {
1631            let activity_feed = tui_bridge.activity_feed().lock().unwrap();
1632            let activity_block = Block::default()
1633                .borders(Borders::NONE)
1634                .style(Style::default().bg(Color::Reset));
1635            let activity_inner = activity_block.inner(chunks[chunk_idx]);
1636            f.render_widget(activity_block, chunks[chunk_idx]);
1637            activity_feed.render(activity_inner, f.buffer_mut());
1638            chunk_idx += 1;
1639        }
1640
1641        // Draw status bar
1642        {
1643            let status_style = if status_is_error {
1644                Style::default().fg(Color::Red).bg(Color::Reset)
1645            } else {
1646                Style::default().fg(Color::Yellow)
1647            };
1648
1649            let status = Paragraph::new(Line::from(vec![
1650                Span::styled(" ● ", Style::default().fg(Color::Green)),
1651                Span::styled(status_message, status_style),
1652            ]));
1653            f.render_widget(status, chunks[chunk_idx]);
1654            chunk_idx += 1;
1655        }
1656
1657        // Draw input area with border
1658        {
1659            let input_block = Block::default()
1660                .borders(Borders::ALL)
1661                .title(" Input (Esc or /exit to quit) ")
1662                .title_style(Style::default().fg(Color::Cyan));
1663
1664            let input_inner = input_block.inner(chunks[chunk_idx]);
1665            f.render_widget(input_block, chunks[chunk_idx]);
1666
1667            // Build input line with cursor
1668            let before_cursor = &input_text[..cursor_pos];
1669            let at_cursor = if cursor_pos < input_text.len() {
1670                &input_text[cursor_pos
1671                    ..cursor_pos
1672                        + input_text[cursor_pos..]
1673                            .chars()
1674                            .next()
1675                            .map(|c| c.len_utf8())
1676                            .unwrap_or(0)]
1677            } else {
1678                " "
1679            };
1680            let after_cursor = if cursor_pos < input_text.len() {
1681                &input_text[cursor_pos + at_cursor.len()..]
1682            } else {
1683                ""
1684            };
1685
1686            let cursor_style = if cursor_blink_state {
1687                Style::default().bg(Color::White).fg(Color::Black)
1688            } else {
1689                Style::default().bg(Color::Reset).fg(Color::Reset)
1690            };
1691
1692            let input_line = if input_text.is_empty() {
1693                Line::from(vec![Span::styled(
1694                    "Type your message here...",
1695                    Style::default().fg(Color::DarkGray),
1696                )])
1697            } else {
1698                Line::from(vec![
1699                    Span::raw(before_cursor),
1700                    Span::styled(at_cursor, cursor_style),
1701                    Span::raw(after_cursor),
1702                ])
1703            };
1704
1705            let input_para = Paragraph::new(input_line).wrap(Wrap { trim: false });
1706            f.render_widget(input_para, input_inner);
1707        }
1708
1709        // Draw file autocomplete popup if active
1710        if let Some(ref ac) = file_autocomplete {
1711            if ac.is_active && !ac.matches.is_empty() {
1712                // Find the input area chunk (it's the last one)
1713                let input_area = chunks.last().unwrap();
1714
1715                // Calculate popup area above input
1716                let popup_area = calculate_popup_area(*input_area, ac.matches.len());
1717
1718                // Create and render the autocomplete widget
1719                let widget = FileAutocompleteWidget::new(&ac.matches, ac.selected_index, &ac.query);
1720                f.render_widget(widget, popup_area);
1721            }
1722        }
1723    }
1724}
1725
1726#[cfg(test)]
1727mod tests {
1728    use super::*;
1729
1730    /// Create a test config for AgentBridge
1731    fn create_test_config() -> limit_llm::Config {
1732        use limit_llm::ProviderConfig;
1733        let mut providers = std::collections::HashMap::new();
1734        providers.insert(
1735            "anthropic".to_string(),
1736            ProviderConfig {
1737                api_key: Some("test-key".to_string()),
1738                model: "claude-3-5-sonnet-20241022".to_string(),
1739                base_url: None,
1740                max_tokens: 4096,
1741                timeout: 60,
1742                max_iterations: 100,
1743                thinking_enabled: false,
1744                clear_thinking: true,
1745            },
1746        );
1747        limit_llm::Config {
1748            provider: "anthropic".to_string(),
1749            providers,
1750        }
1751    }
1752
1753    #[test]
1754    fn test_tui_bridge_new() {
1755        let config = create_test_config();
1756        let agent_bridge = AgentBridge::new(config).unwrap();
1757        let (_tx, rx) = mpsc::unbounded_channel();
1758
1759        let tui_bridge = TuiBridge::new(agent_bridge, rx).unwrap();
1760        assert_eq!(tui_bridge.state(), TuiState::Idle);
1761    }
1762
1763    #[test]
1764    fn test_tui_bridge_state() {
1765        let config = create_test_config();
1766        let agent_bridge = AgentBridge::new(config).unwrap();
1767        let (tx, rx) = mpsc::unbounded_channel();
1768
1769        let mut tui_bridge = TuiBridge::new(agent_bridge, rx).unwrap();
1770
1771        tx.send(AgentEvent::Thinking).unwrap();
1772        tui_bridge.process_events().unwrap();
1773        assert!(matches!(tui_bridge.state(), TuiState::Thinking));
1774
1775        tx.send(AgentEvent::Done).unwrap();
1776        tui_bridge.process_events().unwrap();
1777        assert_eq!(tui_bridge.state(), TuiState::Idle);
1778    }
1779
1780    #[test]
1781    fn test_tui_bridge_chat_view() {
1782        let config = create_test_config();
1783        let agent_bridge = AgentBridge::new(config).unwrap();
1784        let (_tx, rx) = mpsc::unbounded_channel();
1785
1786        let tui_bridge = TuiBridge::new(agent_bridge, rx).unwrap();
1787
1788        tui_bridge.add_user_message("Hello".to_string());
1789        assert_eq!(tui_bridge.chat_view().lock().unwrap().message_count(), 3); // 1 user + 2 system (welcome + model)
1790    }
1791
1792    #[test]
1793    fn test_tui_state_default() {
1794        let state = TuiState::default();
1795        assert_eq!(state, TuiState::Idle);
1796    }
1797}