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
23fn 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#[derive(Debug, Clone, PartialEq, Default)]
38pub enum TuiState {
39 #[default]
40 Idle,
41 Thinking,
42}
43
44pub struct TuiBridge {
46 agent_bridge: Arc<Mutex<AgentBridge>>,
48 event_rx: mpsc::UnboundedReceiver<AgentEvent>,
50 state: Arc<Mutex<TuiState>>,
52 chat_view: Arc<Mutex<ChatView>>,
54 activity_feed: Arc<Mutex<ActivityFeed>>,
56 spinner: Arc<Mutex<Spinner>>,
59 messages: Arc<Mutex<Vec<limit_llm::Message>>>,
61 total_input_tokens: Arc<Mutex<u64>>,
63 total_output_tokens: Arc<Mutex<u64>>,
65 session_manager: Arc<Mutex<SessionManager>>,
67 session_id: Arc<Mutex<String>>,
69}
70
71impl TuiBridge {
72 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 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 let messages: Vec<limit_llm::Message> = Vec::new();
89
90 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 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 }
113 limit_llm::Role::Tool => {
114 }
116 }
117 }
118
119 tracing::info!("Loaded {} messages into chat view", messages.len());
120
121 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 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 pub fn agent_bridge_arc(&self) -> Arc<Mutex<AgentBridge>> {
151 self.agent_bridge.clone()
152 }
153
154 #[allow(dead_code)]
156 pub fn agent_bridge(&self) -> std::sync::MutexGuard<'_, AgentBridge> {
157 self.agent_bridge.lock().unwrap()
158 }
159
160 pub fn state(&self) -> TuiState {
162 self.state.lock().unwrap().clone()
163 }
164
165 pub fn chat_view(&self) -> &Arc<Mutex<ChatView>> {
167 &self.chat_view
168 }
169
170 pub fn spinner(&self) -> &Arc<Mutex<Spinner>> {
172 &self.spinner
173 }
174
175 pub fn activity_feed(&self) -> &Arc<Mutex<ActivityFeed>> {
177 &self.activity_feed
178 }
179
180 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 self.activity_feed.lock().unwrap().add(activity_msg, true);
191 }
192 AgentEvent::ToolComplete { name: _, result: _ } => {
193 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 self.activity_feed.lock().unwrap().complete_all();
206 }
207 AgentEvent::Error(err) => {
208 *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 *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 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 pub fn tick_spinner(&self) {
303 self.spinner.lock().unwrap().tick();
304 }
305
306 pub fn is_busy(&self) -> bool {
308 !matches!(self.state(), TuiState::Idle)
309 }
310
311 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 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 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 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
385pub 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 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 pub fn run(&mut self) -> Result<(), CliError> {
423 execute!(std::io::stdout(), EnterAlternateScreen)
425 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
426
427 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 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 self.tui_bridge.process_events()?;
452
453 if matches!(self.tui_bridge.state(), TuiState::Thinking) {
455 self.tui_bridge.tick_spinner();
456 }
457
458 self.update_status();
460
461 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 self.tick_cursor_blink();
485 }
486
487 self.draw()?;
489 }
490
491 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 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 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 debug_log(&format!(
541 "Key: {:?} mod={:?} kind={:?}",
542 key.code, key.modifiers, key.kind
543 ));
544
545 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 let term_height = self.terminal.size().map(|s| s.height).unwrap_or(24);
555 let viewport_height = term_height
556 .saturating_sub(1) .saturating_sub(7); 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 if self.tui_bridge.is_busy() {
584 debug_log("Agent busy, ignoring");
585 return Ok(());
586 }
587
588 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 KeyCode::Char(c)
627 if key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT =>
628 {
629 self.input_text.insert(self.cursor_pos, c);
631 self.cursor_pos += c.len_utf8();
632 }
633 _ => {
634 }
636 }
637
638 Ok(())
639 }
640
641 fn handle_backspace(&mut self, key: &KeyEvent) -> bool {
643 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 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 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 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 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 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 self.tui_bridge.add_user_message(text.clone());
772
773 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 std::thread::spawn(move || {
785 let rt = tokio::runtime::Runtime::new().unwrap();
787
788 #[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 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 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 fn handle_session_new(&mut self) -> Result<(), CliError> {
878 tracing::info!("Session new command detected");
879
880 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 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 if let Ok(mut id_guard) = self.tui_bridge.session_id.try_lock() {
905 *id_guard = new_session_id.clone();
906 }
907
908 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 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 fn handle_session_load(&mut self, session_id: &str) -> Result<(), CliError> {
942 tracing::info!("Session load command detected for session: {}", session_id);
943
944 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 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 sessions
967 .iter()
968 .find(|s| s.id == session_id)
969 .or_else(|| sessions.iter().find(|s| s.id.starts_with(session_id)))
971 } else {
972 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 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 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 if let Ok(mut messages_guard) = self.tui_bridge.messages.try_lock() {
1008 *messages_guard = messages.clone();
1009 }
1010
1011 if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1013 chat.clear();
1014
1015 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 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 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 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 #[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 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) } else {
1130 0
1131 };
1132
1133 let constraints: Vec<Constraint> = vec![Constraint::Percentage(90)]; let mut constraints = constraints;
1136 if activity_height > 0 {
1137 constraints.push(Constraint::Length(activity_height)); }
1139 constraints.push(Constraint::Length(1)); constraints.push(Constraint::Length(6)); 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 {
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 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 {
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 {
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 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 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); }
1313
1314 #[test]
1315 fn test_tui_state_default() {
1316 let state = TuiState::default();
1317 assert_eq!(state, TuiState::Idle);
1318 }
1319}