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