Skip to main content

limit_cli/
tui_bridge.rs

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