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        Ok(Self {
417            tui_bridge,
418            terminal,
419            running: true,
420            input_text: String::new(),
421            cursor_pos: 0,
422            status_message: "Ready - Type a message and press Enter".to_string(),
423            status_is_error: false,
424            cursor_blink_state: true,
425            cursor_blink_timer: std::time::Instant::now(),
426            mouse_selection_start: None,
427            clipboard: ClipboardManager::new()
428                .inspect_err(|e| tracing::warn!("Clipboard unavailable: {}", e))
429                .ok(),
430        })
431    }
432
433    /// Run the TUI event loop
434    pub fn run(&mut self) -> Result<(), CliError> {
435        // Enter alternate screen - creates a clean buffer for TUI
436        execute!(std::io::stdout(), EnterAlternateScreen)
437            .map_err(|e| CliError::IoError(io::Error::other(e)))?;
438
439        // Enable mouse capture for scroll support
440        execute!(std::io::stdout(), EnableMouseCapture)
441            .map_err(|e| CliError::IoError(io::Error::other(e)))?;
442
443        // Enable bracketed paste for multi-line paste support
444        execute!(std::io::stdout(), EnableBracketedPaste)
445            .map_err(|e| CliError::IoError(io::Error::other(e)))?;
446
447        crossterm::terminal::enable_raw_mode()
448            .map_err(|e| CliError::IoError(io::Error::other(e)))?;
449
450        // Guard to ensure cleanup on panic
451        struct AlternateScreenGuard;
452        impl Drop for AlternateScreenGuard {
453            fn drop(&mut self) {
454                let _ = crossterm::terminal::disable_raw_mode();
455                let _ = execute!(std::io::stdout(), DisableBracketedPaste);
456                let _ = execute!(std::io::stdout(), DisableMouseCapture);
457                let _ = execute!(std::io::stdout(), LeaveAlternateScreen);
458            }
459        }
460        let _guard = AlternateScreenGuard;
461
462        self.run_inner()
463    }
464
465    fn run_inner(&mut self) -> Result<(), CliError> {
466        while self.running {
467            // Process events from the agent
468            self.tui_bridge.process_events()?;
469
470            // Update spinner if in thinking state
471            if matches!(self.tui_bridge.state(), TuiState::Thinking) {
472                self.tui_bridge.tick_spinner();
473            }
474
475            // Update status based on state
476            self.update_status();
477
478            // Handle user input with poll timeout
479            if crossterm::event::poll(std::time::Duration::from_millis(100))
480                .map_err(|e| CliError::IoError(io::Error::other(e)))?
481            {
482                match event::read().map_err(|e| CliError::IoError(io::Error::other(e)))? {
483                    Event::Key(key) if key.kind == KeyEventKind::Press => {
484                        self.handle_key_event(key)?;
485                    }
486                    Event::Mouse(mouse) => match mouse.kind {
487                        MouseEventKind::Down(MouseButton::Left) => {
488                            self.mouse_selection_start = Some((mouse.column, mouse.row));
489                            // Map screen position to message/offset and start selection
490                            let chat = self.tui_bridge.chat_view().lock().unwrap();
491                            if let Some((msg_idx, char_offset)) =
492                                chat.screen_to_text_pos(mouse.column, mouse.row)
493                            {
494                                drop(chat);
495                                self.tui_bridge
496                                    .chat_view()
497                                    .lock()
498                                    .unwrap()
499                                    .start_selection(msg_idx, char_offset);
500                            } else {
501                                drop(chat);
502                                self.tui_bridge
503                                    .chat_view()
504                                    .lock()
505                                    .unwrap()
506                                    .clear_selection();
507                            }
508                        }
509                        MouseEventKind::Drag(MouseButton::Left) => {
510                            if self.mouse_selection_start.is_some() {
511                                // Extend selection to current position
512                                let chat = self.tui_bridge.chat_view().lock().unwrap();
513                                if let Some((msg_idx, char_offset)) =
514                                    chat.screen_to_text_pos(mouse.column, mouse.row)
515                                {
516                                    drop(chat);
517                                    self.tui_bridge
518                                        .chat_view()
519                                        .lock()
520                                        .unwrap()
521                                        .extend_selection(msg_idx, char_offset);
522                                }
523                            }
524                        }
525                        MouseEventKind::Up(MouseButton::Left) => {
526                            self.mouse_selection_start = None;
527                        }
528                        MouseEventKind::ScrollUp => {
529                            let mut chat = self.tui_bridge.chat_view().lock().unwrap();
530                            chat.scroll_up();
531                        }
532                        MouseEventKind::ScrollDown => {
533                            let mut chat = self.tui_bridge.chat_view().lock().unwrap();
534                            chat.scroll_down();
535                        }
536                        _ => {}
537                    },
538                    Event::Paste(pasted) => {
539                        if !self.tui_bridge.is_busy() {
540                            self.insert_paste(&pasted);
541                        }
542                    }
543                    _ => {}
544                }
545            } else {
546                // No key event - tick cursor blink
547                self.tick_cursor_blink();
548            }
549
550            // Draw the TUI
551            self.draw()?;
552        }
553
554        // Save session before exiting
555        if let Err(e) = self.tui_bridge.save_session() {
556            tracing::error!("Failed to save session: {}", e);
557        }
558
559        Ok(())
560    }
561
562    fn update_status(&mut self) {
563        let session_id = self.tui_bridge.session_id();
564        let has_activity = self
565            .tui_bridge
566            .activity_feed()
567            .lock()
568            .unwrap()
569            .has_in_progress();
570
571        match self.tui_bridge.state() {
572            TuiState::Idle => {
573                if has_activity {
574                    // Show spinner when there are in-progress activities
575                    let spinner = self.tui_bridge.spinner().lock().unwrap();
576                    self.status_message = format!("{} Processing...", spinner.current_frame());
577                } else {
578                    self.status_message = format!(
579                        "Ready | Session: {}",
580                        session_id.chars().take(8).collect::<String>()
581                    );
582                }
583                self.status_is_error = false;
584            }
585            TuiState::Thinking => {
586                let spinner = self.tui_bridge.spinner().lock().unwrap();
587                self.status_message = format!("{} Thinking...", spinner.current_frame());
588                self.status_is_error = false;
589            }
590        }
591    }
592
593    /// Insert pasted text at cursor position without submitting
594    fn insert_paste(&mut self, text: &str) {
595        // Enforce size limit to prevent memory issues
596        let text = if text.len() > MAX_PASTE_SIZE {
597            self.status_message = "Paste truncated (too large)".to_string();
598            self.status_is_error = true;
599            // Find valid UTF-8 boundary at approximately MAX_PASTE_SIZE
600            &text[..text
601                .char_indices()
602                .nth(MAX_PASTE_SIZE)
603                .map(|(i, _)| i)
604                .unwrap_or(text.len())]
605        } else {
606            text
607        };
608
609        // Normalize newlines (some terminals convert \n to \r)
610        let normalized = text.replace("\r", "\n");
611        self.input_text.insert_str(self.cursor_pos, &normalized);
612        self.cursor_pos += normalized.len();
613    }
614
615    /// Check if the current key event is a copy/paste shortcut
616    /// Returns true for Ctrl+C/V on Linux/Windows, Cmd+C/V on macOS
617    fn is_copy_paste_modifier(&self, key: &KeyEvent, char: char) -> bool {
618        #[cfg(target_os = "macos")]
619        {
620            key.code == KeyCode::Char(char) && key.modifiers.contains(KeyModifiers::SUPER)
621        }
622        #[cfg(not(target_os = "macos"))]
623        {
624            key.code == KeyCode::Char(char) && key.modifiers.contains(KeyModifiers::CONTROL)
625        }
626    }
627
628    fn tick_cursor_blink(&mut self) {
629        // Blink every 500ms for standard terminal cursor behavior
630        if self.cursor_blink_timer.elapsed().as_millis() > 500 {
631            self.cursor_blink_state = !self.cursor_blink_state;
632            self.cursor_blink_timer = std::time::Instant::now();
633        }
634    }
635
636    fn handle_key_event(&mut self, key: KeyEvent) -> Result<(), CliError> {
637        // Direct file logging (always works)
638        debug_log(&format!(
639            "Key: {:?} mod={:?} kind={:?}",
640            key.code, key.modifiers, key.kind
641        ));
642
643        // Copy selection to clipboard (Ctrl/Cmd+C)
644        if self.is_copy_paste_modifier(&key, 'c') {
645            let mut chat = self.tui_bridge.chat_view().lock().unwrap();
646            if chat.has_selection() {
647                if let Some(selected) = chat.get_selected_text() {
648                    if !selected.is_empty() {
649                        if let Some(ref clipboard) = self.clipboard {
650                            match clipboard.set_text(&selected) {
651                                Ok(()) => {
652                                    self.status_message = "Copied to clipboard".to_string();
653                                    self.status_is_error = false;
654                                }
655                                Err(e) => {
656                                    self.status_message = format!("Clipboard error: {}", e);
657                                    self.status_is_error = true;
658                                }
659                            }
660                        } else {
661                            self.status_message = "Clipboard not available".to_string();
662                            self.status_is_error = true;
663                        }
664                    }
665                    chat.clear_selection();
666                }
667                return Ok(());
668            }
669            // On non-macOS, fall through to Ctrl+C exit behavior
670            #[cfg(not(target_os = "macos"))]
671            {
672                // Ctrl+C with no selection will exit (handled below)
673            }
674            #[cfg(target_os = "macos")]
675            {
676                return Ok(()); // Cmd+C with no selection does nothing on macOS
677            }
678        }
679
680        // Paste from clipboard (Ctrl/Cmd+V)
681        if self.is_copy_paste_modifier(&key, 'v') && !self.tui_bridge.is_busy() {
682            if let Some(ref clipboard) = self.clipboard {
683                match clipboard.get_text() {
684                    Ok(text) if !text.is_empty() => {
685                        self.insert_paste(&text);
686                    }
687                    Ok(_) => {} // Empty clipboard
688                    Err(e) => {
689                        self.status_message = format!("Could not read clipboard: {}", e);
690                        self.status_is_error = true;
691                    }
692                }
693            } else {
694                self.status_message = "Clipboard not available".to_string();
695                self.status_is_error = true;
696            }
697            return Ok(());
698        }
699
700        // Allow Ctrl+C to exit anytime (only if no selection on non-macOS)
701        #[cfg(not(target_os = "macos"))]
702        {
703            if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c') {
704                debug_log("Ctrl+C - exiting");
705                self.running = false;
706                return Ok(());
707            }
708        }
709
710        // Allow scrolling even when agent is busy
711        // Calculate actual viewport height dynamically
712        let term_height = self.terminal.size().map(|s| s.height).unwrap_or(24);
713        let viewport_height = term_height
714            .saturating_sub(1) // status bar - input area (6 lines) - borders (~2)
715            .saturating_sub(7); // status (1) + input (6) + top/bottom borders (2) = 9
716        match key.code {
717            KeyCode::PageUp => {
718                let mut chat = self.tui_bridge.chat_view().lock().unwrap();
719                chat.scroll_page_up(viewport_height);
720                return Ok(());
721            }
722            KeyCode::PageDown => {
723                let mut chat = self.tui_bridge.chat_view().lock().unwrap();
724                chat.scroll_page_down(viewport_height);
725                return Ok(());
726            }
727            KeyCode::Up => {
728                let mut chat = self.tui_bridge.chat_view().lock().unwrap();
729                chat.scroll_up();
730                return Ok(());
731            }
732            KeyCode::Down => {
733                let mut chat = self.tui_bridge.chat_view().lock().unwrap();
734                chat.scroll_down();
735                return Ok(());
736            }
737            _ => {}
738        }
739
740        // Don't accept input while agent is busy
741        if self.tui_bridge.is_busy() {
742            debug_log("Agent busy, ignoring");
743            return Ok(());
744        }
745
746        // Handle backspace - try multiple detection methods
747        if self.handle_backspace(&key) {
748            debug_log(&format!("Backspace handled, input: {:?}", self.input_text));
749            return Ok(());
750        }
751
752        match key.code {
753            KeyCode::Delete => {
754                if self.cursor_pos < self.input_text.len() {
755                    let next_pos = self.next_char_pos();
756                    self.input_text.drain(self.cursor_pos..next_pos);
757                    debug_log(&format!("Delete: input now: {:?}", self.input_text));
758                }
759            }
760            KeyCode::Left => {
761                if self.cursor_pos > 0 {
762                    self.cursor_pos = self.prev_char_pos();
763                }
764            }
765            KeyCode::Right => {
766                if self.cursor_pos < self.input_text.len() {
767                    self.cursor_pos = self.next_char_pos();
768                }
769            }
770            KeyCode::Home => {
771                self.cursor_pos = 0;
772            }
773            KeyCode::End => {
774                self.cursor_pos = self.input_text.len();
775            }
776            KeyCode::Enter => {
777                self.handle_enter()?;
778            }
779            KeyCode::Esc => {
780                debug_log("Esc pressed, exiting");
781                self.running = false;
782            }
783            // Regular character input (including UTF-8)
784            KeyCode::Char(c)
785                if key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT =>
786            {
787                // Insert the character at cursor position
788                self.input_text.insert(self.cursor_pos, c);
789                self.cursor_pos += c.len_utf8();
790            }
791            _ => {
792                // Ignore other keys
793            }
794        }
795
796        Ok(())
797    }
798
799    /// Handle backspace with multiple detection methods
800    fn handle_backspace(&mut self, key: &KeyEvent) -> bool {
801        // Method 1: Standard Backspace keycode
802        if key.code == KeyCode::Backspace {
803            debug_log("Backspace detected via KeyCode::Backspace");
804            self.delete_char_before_cursor();
805            return true;
806        }
807
808        // Method 2: Ctrl+H (common backspace mapping)
809        if key.code == KeyCode::Char('h') && key.modifiers == KeyModifiers::CONTROL {
810            debug_log("Backspace detected via Ctrl+H");
811            self.delete_char_before_cursor();
812            return true;
813        }
814
815        // Method 3: Check for DEL (127) or BS (8) characters
816        if let KeyCode::Char(c) = key.code {
817            if c == '\x7f' || c == '\x08' {
818                debug_log(&format!("Backspace detected via char code: {}", c as u8));
819                self.delete_char_before_cursor();
820                return true;
821            }
822        }
823
824        false
825    }
826
827    fn delete_char_before_cursor(&mut self) {
828        debug_log(&format!(
829            "delete_char: cursor={}, len={}, input={:?}",
830            self.cursor_pos,
831            self.input_text.len(),
832            self.input_text
833        ));
834        if self.cursor_pos > 0 {
835            let prev_pos = self.prev_char_pos();
836            debug_log(&format!("draining {}..{}", prev_pos, self.cursor_pos));
837            self.input_text.drain(prev_pos..self.cursor_pos);
838            self.cursor_pos = prev_pos;
839            debug_log(&format!(
840                "after delete: cursor={}, input={:?}",
841                self.cursor_pos, self.input_text
842            ));
843        } else {
844            debug_log("cursor at 0, nothing to delete");
845        }
846    }
847
848    fn handle_enter(&mut self) -> Result<(), CliError> {
849        let text = self.input_text.trim().to_string();
850
851        // Clear input FIRST for immediate visual feedback
852        self.input_text.clear();
853        self.cursor_pos = 0;
854
855        if text.is_empty() {
856            return Ok(());
857        }
858
859        tracing::info!("Enter pressed with text: {:?}", text);
860
861        // Handle commands locally (no LLM)
862        let text_lower = text.to_lowercase();
863        if text_lower == "/exit"
864            || text_lower == "/quit"
865            || text_lower == "exit"
866            || text_lower == "quit"
867        {
868            tracing::info!("Exit command detected, exiting");
869            self.running = false;
870            return Ok(());
871        }
872
873        if text_lower == "/clear" || text_lower == "clear" {
874            tracing::info!("Clear command detected");
875            self.tui_bridge.chat_view().lock().unwrap().clear();
876            return Ok(());
877        }
878
879        if text_lower == "/help" || text_lower == "help" {
880            tracing::info!("Help command detected");
881            let help_msg = Message::system(
882                "Available commands:\n\
883                 /help  - Show this help message\n\
884                 /clear - Clear chat history\n\
885                 /exit  - Exit the application\n\
886                 /quit  - Exit the application\n\
887                 /session list  - List all sessions\n\
888                 /session new   - Create a new session\n\
889                 /session load  <id> - Load a session by ID\n\
890                 \n\
891                 Page Up/Down - Scroll chat history"
892                    .to_string(),
893            );
894            self.tui_bridge
895                .chat_view()
896                .lock()
897                .unwrap()
898                .add_message(help_msg);
899            return Ok(());
900        }
901
902        // Handle session commands
903        if text_lower.starts_with("/session ") {
904            let session_cmd = text.strip_prefix("/session ").unwrap();
905            if session_cmd.trim() == "list" {
906                self.handle_session_list()?;
907                return Ok(());
908            } else if session_cmd.trim() == "new" {
909                self.handle_session_new()?;
910                return Ok(());
911            } else if session_cmd.starts_with("load ") {
912                let session_id = session_cmd.strip_prefix("load ").unwrap().trim();
913                self.handle_session_load(session_id)?;
914                return Ok(());
915            } else {
916                let error_msg = Message::system(
917                    "Usage: /session list, /session new, /session load <id>".to_string(),
918                );
919                self.tui_bridge
920                    .chat_view()
921                    .lock()
922                    .unwrap()
923                    .add_message(error_msg);
924                return Ok(());
925            }
926        }
927
928        // Add user message to chat (for display)
929        self.tui_bridge.add_user_message(text.clone());
930
931        // Clone Arcs for the spawned thread
932        let messages = self.tui_bridge.messages.clone();
933        let agent_bridge = self.tui_bridge.agent_bridge_arc();
934        let session_manager = self.tui_bridge.session_manager.clone();
935        let session_id = self.tui_bridge.session_id();
936        let total_input_tokens = self.tui_bridge.total_input_tokens.clone();
937        let total_output_tokens = self.tui_bridge.total_output_tokens.clone();
938
939        tracing::debug!("Spawning LLM processing thread");
940
941        // Spawn a thread to process the message without blocking the UI
942        std::thread::spawn(move || {
943            // Create a new tokio runtime for this thread
944            let rt = tokio::runtime::Runtime::new().unwrap();
945
946            // Safe: we're in a dedicated thread, this won't cause issues
947            #[allow(clippy::await_holding_lock)]
948            rt.block_on(async {
949                let mut messages_guard = messages.lock().unwrap();
950                let mut bridge = agent_bridge.lock().unwrap();
951
952                match bridge.process_message(&text, &mut messages_guard).await {
953                    Ok(_response) => {
954                        // Response already displayed via streaming (ContentChunk events)
955                        // No need to add_message again - would cause duplication
956                        // Auto-save session after successful response
957                        let msgs = messages_guard.clone();
958                        let input_tokens = *total_input_tokens.lock().unwrap();
959                        let output_tokens = *total_output_tokens.lock().unwrap();
960
961                        if let Err(e) = session_manager.lock().unwrap().save_session(
962                            &session_id,
963                            &msgs,
964                            input_tokens,
965                            output_tokens,
966                        ) {
967                            tracing::error!("✗ Failed to auto-save session {}: {}", session_id, e);
968                        } else {
969                            tracing::info!(
970                                "✓ Session {} auto-saved ({} messages, {} in, {} out tokens)",
971                                session_id,
972                                msgs.len(),
973                                input_tokens,
974                                output_tokens
975                            );
976                        }
977                    }
978                    Err(e) => {
979                        tracing::error!("LLM error: {}", e);
980                    }
981                }
982            });
983        });
984
985        Ok(())
986    }
987
988    /// Handle /session list command
989    fn handle_session_list(&self) -> Result<(), CliError> {
990        tracing::info!("Session list command detected");
991        let session_manager = self.tui_bridge.session_manager.lock().unwrap();
992        let current_session_id = self.tui_bridge.session_id();
993
994        match session_manager.list_sessions() {
995            Ok(sessions) => {
996                if sessions.is_empty() {
997                    let msg = Message::system("No sessions found.".to_string());
998                    self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
999                } else {
1000                    let mut output = vec!["Sessions (most recent first):".to_string()];
1001                    for (i, session) in sessions.iter().enumerate() {
1002                        let current = if session.id == current_session_id {
1003                            " (current)"
1004                        } else {
1005                            ""
1006                        };
1007                        let short_id = if session.id.len() > 8 {
1008                            &session.id[..8]
1009                        } else {
1010                            &session.id
1011                        };
1012                        output.push(format!(
1013                            "  {}. {}{} - {} messages, {} in tokens, {} out tokens",
1014                            i + 1,
1015                            short_id,
1016                            current,
1017                            session.message_count,
1018                            session.total_input_tokens,
1019                            session.total_output_tokens
1020                        ));
1021                    }
1022                    let msg = Message::system(output.join("\n"));
1023                    self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1024                }
1025            }
1026            Err(e) => {
1027                let msg = Message::system(format!("Error listing sessions: {}", e));
1028                self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1029            }
1030        }
1031        Ok(())
1032    }
1033
1034    /// Handle /session new command
1035    fn handle_session_new(&mut self) -> Result<(), CliError> {
1036        tracing::info!("Session new command detected");
1037
1038        // Save current session (release lock before proceeding)
1039        let save_result = self.tui_bridge.save_session();
1040        if let Err(e) = &save_result {
1041            tracing::error!("Failed to save current session: {}", e);
1042            let msg = Message::system(format!("⚠ Warning: Failed to save current session: {}", e));
1043            if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1044                chat.add_message(msg);
1045            }
1046        }
1047
1048        // Create new session (separate lock scope)
1049        let new_session_id = {
1050            let session_manager = self.tui_bridge.session_manager.lock().map_err(|e| {
1051                CliError::ConfigError(format!("Failed to acquire session manager lock: {}", e))
1052            })?;
1053
1054            session_manager
1055                .create_new_session()
1056                .map_err(|e| CliError::ConfigError(format!("Failed to create session: {}", e)))?
1057        };
1058
1059        let old_session_id = self.tui_bridge.session_id();
1060
1061        // Update session ID
1062        if let Ok(mut id_guard) = self.tui_bridge.session_id.try_lock() {
1063            *id_guard = new_session_id.clone();
1064        }
1065
1066        // Clear messages and reset token counts (separate locks)
1067        if let Ok(mut messages_guard) = self.tui_bridge.messages.try_lock() {
1068            messages_guard.clear();
1069        }
1070        if let Ok(mut input_guard) = self.tui_bridge.total_input_tokens.try_lock() {
1071            *input_guard = 0;
1072        }
1073        if let Ok(mut output_guard) = self.tui_bridge.total_output_tokens.try_lock() {
1074            *output_guard = 0;
1075        }
1076
1077        tracing::info!(
1078            "Created new session: {} (old: {})",
1079            new_session_id,
1080            old_session_id
1081        );
1082
1083        // Add system message
1084        let session_short_id = if new_session_id.len() > 8 {
1085            &new_session_id[new_session_id.len().saturating_sub(8)..]
1086        } else {
1087            &new_session_id
1088        };
1089        let msg = Message::system(format!("🆕 New session created: {}", session_short_id));
1090
1091        if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1092            chat.add_message(msg);
1093        }
1094
1095        Ok(())
1096    }
1097
1098    /// Handle /session load <id> command
1099    fn handle_session_load(&mut self, session_id: &str) -> Result<(), CliError> {
1100        tracing::info!("Session load command detected for session: {}", session_id);
1101
1102        // Save current session first (release lock before proceeding)
1103        let save_result = self.tui_bridge.save_session();
1104        if let Err(e) = &save_result {
1105            tracing::error!("Failed to save current session: {}", e);
1106            let msg = Message::system(format!("⚠ Warning: Failed to save current session: {}", e));
1107            if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1108                chat.add_message(msg);
1109            }
1110        }
1111
1112        // Find session ID from partial match (acquire locks separately to avoid deadlock)
1113        let (full_session_id, session_info, messages) = {
1114            let session_manager = self.tui_bridge.session_manager.lock().map_err(|e| {
1115                CliError::ConfigError(format!("Failed to acquire session manager lock: {}", e))
1116            })?;
1117
1118            let sessions = session_manager
1119                .list_sessions()
1120                .map_err(|e| CliError::ConfigError(format!("Failed to list sessions: {}", e)))?;
1121
1122            let matched_session = if session_id.len() >= 8 {
1123                // Try exact match first
1124                sessions
1125                    .iter()
1126                    .find(|s| s.id == session_id)
1127                    // Then try prefix match
1128                    .or_else(|| sessions.iter().find(|s| s.id.starts_with(session_id)))
1129            } else {
1130                // Try prefix match for short IDs
1131                sessions.iter().find(|s| s.id.starts_with(session_id))
1132            };
1133
1134            match matched_session {
1135                Some(info) => {
1136                    let full_id = info.id.clone();
1137                    // Load messages within the same lock scope
1138                    let msgs = session_manager.load_session(&full_id).map_err(|e| {
1139                        CliError::ConfigError(format!("Failed to load session {}: {}", full_id, e))
1140                    })?;
1141                    (full_id, info.clone(), msgs)
1142                }
1143                None => {
1144                    let msg = Message::system(format!("❌ Session not found: {}", session_id));
1145                    if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1146                        chat.add_message(msg);
1147                    }
1148                    return Ok(());
1149                }
1150            }
1151        };
1152
1153        // Update session ID and token counts (separate locks)
1154        if let Ok(mut id_guard) = self.tui_bridge.session_id.try_lock() {
1155            *id_guard = full_session_id.clone();
1156        }
1157        if let Ok(mut input_guard) = self.tui_bridge.total_input_tokens.try_lock() {
1158            *input_guard = session_info.total_input_tokens;
1159        }
1160        if let Ok(mut output_guard) = self.tui_bridge.total_output_tokens.try_lock() {
1161            *output_guard = session_info.total_output_tokens;
1162        }
1163
1164        // Update messages in TUI bridge
1165        if let Ok(mut messages_guard) = self.tui_bridge.messages.try_lock() {
1166            *messages_guard = messages.clone();
1167        }
1168
1169        // Clear chat view and reload messages
1170        if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1171            chat.clear();
1172
1173            // Reload messages into chat view (no additional locks needed)
1174            for msg in &messages {
1175                match msg.role {
1176                    limit_llm::Role::User => {
1177                        let content = msg.content.as_deref().unwrap_or("");
1178                        let chat_msg = Message::user(content.to_string());
1179                        chat.add_message(chat_msg);
1180                    }
1181                    limit_llm::Role::Assistant => {
1182                        let content = msg.content.as_deref().unwrap_or("");
1183                        let chat_msg = Message::assistant(content.to_string());
1184                        chat.add_message(chat_msg);
1185                    }
1186                    _ => {}
1187                }
1188            }
1189
1190            tracing::info!(
1191                "Loaded session: {} ({} messages)",
1192                full_session_id,
1193                messages.len()
1194            );
1195
1196            // Add system message
1197            let session_short_id = if full_session_id.len() > 8 {
1198                &full_session_id[full_session_id.len().saturating_sub(8)..]
1199            } else {
1200                &full_session_id
1201            };
1202            let msg = Message::system(format!(
1203                "📂 Loaded session: {} ({} messages, {} in tokens, {} out tokens)",
1204                session_short_id,
1205                messages.len(),
1206                session_info.total_input_tokens,
1207                session_info.total_output_tokens
1208            ));
1209            chat.add_message(msg);
1210        }
1211
1212        Ok(())
1213    }
1214
1215    fn prev_char_pos(&self) -> usize {
1216        if self.cursor_pos == 0 {
1217            return 0;
1218        }
1219        // Start ONE position before cursor, then find char boundary
1220        let mut pos = self.cursor_pos - 1;
1221        while pos > 0 && !self.input_text.is_char_boundary(pos) {
1222            pos -= 1;
1223        }
1224        pos
1225    }
1226
1227    fn next_char_pos(&self) -> usize {
1228        if self.cursor_pos >= self.input_text.len() {
1229            return self.input_text.len();
1230        }
1231        // Start ONE position after cursor, then find char boundary
1232        let mut pos = self.cursor_pos + 1;
1233        while pos < self.input_text.len() && !self.input_text.is_char_boundary(pos) {
1234            pos += 1;
1235        }
1236        pos
1237    }
1238
1239    fn draw(&mut self) -> Result<(), CliError> {
1240        let chat_view = self.tui_bridge.chat_view().clone();
1241        let state = self.tui_bridge.state();
1242        let input_text = self.input_text.clone();
1243        let cursor_pos = self.cursor_pos;
1244        let status_message = self.status_message.clone();
1245        let status_is_error = self.status_is_error;
1246        let cursor_blink_state = self.cursor_blink_state;
1247        let tui_bridge = &self.tui_bridge;
1248
1249        self.terminal
1250            .draw(|f| {
1251                Self::draw_ui(
1252                    f,
1253                    &chat_view,
1254                    state,
1255                    &input_text,
1256                    cursor_pos,
1257                    &status_message,
1258                    status_is_error,
1259                    cursor_blink_state,
1260                    tui_bridge,
1261                );
1262            })
1263            .map_err(|e| CliError::IoError(io::Error::other(e)))?;
1264
1265        Ok(())
1266    }
1267
1268    /// Draw the TUI interface
1269    #[allow(clippy::too_many_arguments)]
1270    fn draw_ui(
1271        f: &mut Frame,
1272        chat_view: &Arc<Mutex<ChatView>>,
1273        _state: TuiState,
1274        input_text: &str,
1275        cursor_pos: usize,
1276        status_message: &str,
1277        status_is_error: bool,
1278        cursor_blink_state: bool,
1279        tui_bridge: &TuiBridge,
1280    ) {
1281        let size = f.area();
1282
1283        // Check if we have activities to show
1284        let activity_count = tui_bridge.activity_feed().lock().unwrap().len();
1285        let activity_height = if activity_count > 0 {
1286            (activity_count as u16).min(3) // Max 3 lines for activity feed
1287        } else {
1288            0
1289        };
1290
1291        // Build constraints based on whether we have activities
1292        let constraints: Vec<Constraint> = vec![Constraint::Percentage(90)]; // Chat view
1293        let mut constraints = constraints;
1294        if activity_height > 0 {
1295            constraints.push(Constraint::Length(activity_height)); // Activity feed
1296        }
1297        constraints.push(Constraint::Length(1)); // Status bar
1298        constraints.push(Constraint::Length(6)); // Input area
1299
1300        // Split the screen
1301        let chunks = Layout::default()
1302            .direction(Direction::Vertical)
1303            .constraints(constraints.as_slice())
1304            .split(size);
1305
1306        let mut chunk_idx = 0;
1307
1308        // Draw chat view with border
1309        {
1310            let chat = chat_view.lock().unwrap();
1311            let total_input = tui_bridge.total_input_tokens();
1312            let total_output = tui_bridge.total_output_tokens();
1313            let title = format!(" Chat (↑{} ↓{}) ", total_input, total_output);
1314            let chat_block = Block::default()
1315                .borders(Borders::ALL)
1316                .title(title)
1317                .title_style(
1318                    Style::default()
1319                        .fg(Color::Cyan)
1320                        .add_modifier(Modifier::BOLD),
1321                );
1322            f.render_widget(&*chat, chat_block.inner(chunks[chunk_idx]));
1323            f.render_widget(chat_block, chunks[chunk_idx]);
1324            chunk_idx += 1;
1325        }
1326
1327        // Draw activity feed if present
1328        if activity_height > 0 {
1329            let activity_feed = tui_bridge.activity_feed().lock().unwrap();
1330            let activity_block = Block::default()
1331                .borders(Borders::NONE)
1332                .style(Style::default().bg(Color::Reset));
1333            let activity_inner = activity_block.inner(chunks[chunk_idx]);
1334            f.render_widget(activity_block, chunks[chunk_idx]);
1335            activity_feed.render(activity_inner, f.buffer_mut());
1336            chunk_idx += 1;
1337        }
1338
1339        // Draw status bar
1340        {
1341            let status_style = if status_is_error {
1342                Style::default().fg(Color::Red).bg(Color::Reset)
1343            } else {
1344                Style::default().fg(Color::Yellow)
1345            };
1346
1347            let status = Paragraph::new(Line::from(vec![
1348                Span::styled(" ● ", Style::default().fg(Color::Green)),
1349                Span::styled(status_message, status_style),
1350            ]));
1351            f.render_widget(status, chunks[chunk_idx]);
1352            chunk_idx += 1;
1353        }
1354
1355        // Draw input area with border
1356        {
1357            let input_block = Block::default()
1358                .borders(Borders::ALL)
1359                .title(" Input (Esc to quit) ")
1360                .title_style(Style::default().fg(Color::Cyan));
1361
1362            let input_inner = input_block.inner(chunks[chunk_idx]);
1363            f.render_widget(input_block, chunks[chunk_idx]);
1364
1365            // Build input line with cursor
1366            let before_cursor = &input_text[..cursor_pos];
1367            let at_cursor = if cursor_pos < input_text.len() {
1368                &input_text[cursor_pos
1369                    ..cursor_pos
1370                        + input_text[cursor_pos..]
1371                            .chars()
1372                            .next()
1373                            .map(|c| c.len_utf8())
1374                            .unwrap_or(0)]
1375            } else {
1376                " "
1377            };
1378            let after_cursor = if cursor_pos < input_text.len() {
1379                &input_text[cursor_pos + at_cursor.len()..]
1380            } else {
1381                ""
1382            };
1383
1384            let cursor_style = if cursor_blink_state {
1385                Style::default().bg(Color::White).fg(Color::Black)
1386            } else {
1387                Style::default().bg(Color::Reset).fg(Color::Reset)
1388            };
1389
1390            let input_line = if input_text.is_empty() {
1391                Line::from(vec![Span::styled(
1392                    "Type your message here...",
1393                    Style::default().fg(Color::DarkGray),
1394                )])
1395            } else {
1396                Line::from(vec![
1397                    Span::raw(before_cursor),
1398                    Span::styled(at_cursor, cursor_style),
1399                    Span::raw(after_cursor),
1400                ])
1401            };
1402
1403            let input_para = Paragraph::new(input_line).wrap(Wrap { trim: false });
1404            f.render_widget(input_para, input_inner);
1405        }
1406    }
1407}
1408
1409#[cfg(test)]
1410mod tests {
1411    use super::*;
1412
1413    /// Create a test config for AgentBridge
1414    fn create_test_config() -> limit_llm::Config {
1415        use limit_llm::ProviderConfig;
1416        let mut providers = std::collections::HashMap::new();
1417        providers.insert(
1418            "anthropic".to_string(),
1419            ProviderConfig {
1420                api_key: Some("test-key".to_string()),
1421                model: "claude-3-5-sonnet-20241022".to_string(),
1422                base_url: None,
1423                max_tokens: 4096,
1424                timeout: 60,
1425                max_iterations: 100,
1426                thinking_enabled: false,
1427                clear_thinking: true,
1428            },
1429        );
1430        limit_llm::Config {
1431            provider: "anthropic".to_string(),
1432            providers,
1433        }
1434    }
1435
1436    #[test]
1437    fn test_tui_bridge_new() {
1438        let config = create_test_config();
1439        let agent_bridge = AgentBridge::new(config).unwrap();
1440        let (_tx, rx) = mpsc::unbounded_channel();
1441
1442        let tui_bridge = TuiBridge::new(agent_bridge, rx).unwrap();
1443        assert_eq!(tui_bridge.state(), TuiState::Idle);
1444    }
1445
1446    #[test]
1447    fn test_tui_bridge_state() {
1448        let config = create_test_config();
1449        let agent_bridge = AgentBridge::new(config).unwrap();
1450        let (tx, rx) = mpsc::unbounded_channel();
1451
1452        let mut tui_bridge = TuiBridge::new(agent_bridge, rx).unwrap();
1453
1454        tx.send(AgentEvent::Thinking).unwrap();
1455        tui_bridge.process_events().unwrap();
1456        assert!(matches!(tui_bridge.state(), TuiState::Thinking));
1457
1458        tx.send(AgentEvent::Done).unwrap();
1459        tui_bridge.process_events().unwrap();
1460        assert_eq!(tui_bridge.state(), TuiState::Idle);
1461    }
1462
1463    #[test]
1464    fn test_tui_bridge_chat_view() {
1465        let config = create_test_config();
1466        let agent_bridge = AgentBridge::new(config).unwrap();
1467        let (_tx, rx) = mpsc::unbounded_channel();
1468
1469        let tui_bridge = TuiBridge::new(agent_bridge, rx).unwrap();
1470
1471        tui_bridge.add_user_message("Hello".to_string());
1472        assert_eq!(tui_bridge.chat_view().lock().unwrap().message_count(), 3); // 1 user + 2 system (welcome + model)
1473    }
1474
1475    #[test]
1476    fn test_tui_state_default() {
1477        let state = TuiState::default();
1478        assert_eq!(state, TuiState::Idle);
1479    }
1480}