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