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
24const MAX_PASTE_SIZE: usize = 100 * 1024;
26
27fn 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#[derive(Debug, Clone, PartialEq, Default)]
42pub enum TuiState {
43 #[default]
44 Idle,
45 Thinking,
46}
47
48pub struct TuiBridge {
50 agent_bridge: Arc<Mutex<AgentBridge>>,
52 event_rx: mpsc::UnboundedReceiver<AgentEvent>,
54 state: Arc<Mutex<TuiState>>,
56 chat_view: Arc<Mutex<ChatView>>,
58 activity_feed: Arc<Mutex<ActivityFeed>>,
60 spinner: Arc<Mutex<Spinner>>,
63 messages: Arc<Mutex<Vec<limit_llm::Message>>>,
65 total_input_tokens: Arc<Mutex<u64>>,
67 total_output_tokens: Arc<Mutex<u64>>,
69 session_manager: Arc<Mutex<SessionManager>>,
71 session_id: Arc<Mutex<String>>,
73}
74
75impl TuiBridge {
76 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 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 let messages: Vec<limit_llm::Message> = Vec::new();
93
94 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 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 }
117 limit_llm::Role::Tool => {
118 }
120 }
121 }
122
123 tracing::info!("Loaded {} messages into chat view", messages.len());
124
125 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 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 pub fn agent_bridge_arc(&self) -> Arc<Mutex<AgentBridge>> {
155 self.agent_bridge.clone()
156 }
157
158 #[allow(dead_code)]
160 pub fn agent_bridge(&self) -> std::sync::MutexGuard<'_, AgentBridge> {
161 self.agent_bridge.lock().unwrap()
162 }
163
164 pub fn state(&self) -> TuiState {
166 self.state.lock().unwrap().clone()
167 }
168
169 pub fn chat_view(&self) -> &Arc<Mutex<ChatView>> {
171 &self.chat_view
172 }
173
174 pub fn spinner(&self) -> &Arc<Mutex<Spinner>> {
176 &self.spinner
177 }
178
179 pub fn activity_feed(&self) -> &Arc<Mutex<ActivityFeed>> {
181 &self.activity_feed
182 }
183
184 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 self.activity_feed.lock().unwrap().add(activity_msg, true);
195 }
196 AgentEvent::ToolComplete { name: _, result: _ } => {
197 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 self.activity_feed.lock().unwrap().complete_all();
210 }
211 AgentEvent::Error(err) => {
212 *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 *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 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 pub fn tick_spinner(&self) {
307 self.spinner.lock().unwrap().tick();
308 }
309
310 pub fn is_busy(&self) -> bool {
312 !matches!(self.state(), TuiState::Idle)
313 }
314
315 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 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 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 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
389pub 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_start: Option<(u16, u16)>,
402 clipboard: Option<ClipboardManager>,
404}
405
406impl TuiApp {
407 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 pub fn run(&mut self) -> Result<(), CliError> {
435 execute!(std::io::stdout(), EnterAlternateScreen)
437 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
438
439 execute!(std::io::stdout(), EnableMouseCapture)
441 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
442
443 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 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 self.tui_bridge.process_events()?;
469
470 if matches!(self.tui_bridge.state(), TuiState::Thinking) {
472 self.tui_bridge.tick_spinner();
473 }
474
475 self.update_status();
477
478 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 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 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 self.tick_cursor_blink();
548 }
549
550 self.draw()?;
552 }
553
554 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 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 fn insert_paste(&mut self, text: &str) {
595 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 &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 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 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 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 debug_log(&format!(
639 "Key: {:?} mod={:?} kind={:?}",
640 key.code, key.modifiers, key.kind
641 ));
642
643 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 #[cfg(not(target_os = "macos"))]
671 {
672 }
674 #[cfg(target_os = "macos")]
675 {
676 return Ok(()); }
678 }
679
680 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(_) => {} 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 #[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 let term_height = self.terminal.size().map(|s| s.height).unwrap_or(24);
713 let viewport_height = term_height
714 .saturating_sub(1) .saturating_sub(7); 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 if self.tui_bridge.is_busy() {
742 debug_log("Agent busy, ignoring");
743 return Ok(());
744 }
745
746 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 KeyCode::Char(c)
785 if key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT =>
786 {
787 self.input_text.insert(self.cursor_pos, c);
789 self.cursor_pos += c.len_utf8();
790 }
791 _ => {
792 }
794 }
795
796 Ok(())
797 }
798
799 fn handle_backspace(&mut self, key: &KeyEvent) -> bool {
801 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 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 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 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 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 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 self.tui_bridge.add_user_message(text.clone());
930
931 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 std::thread::spawn(move || {
943 let rt = tokio::runtime::Runtime::new().unwrap();
945
946 #[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 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 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 fn handle_session_new(&mut self) -> Result<(), CliError> {
1036 tracing::info!("Session new command detected");
1037
1038 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 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 if let Ok(mut id_guard) = self.tui_bridge.session_id.try_lock() {
1063 *id_guard = new_session_id.clone();
1064 }
1065
1066 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 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 fn handle_session_load(&mut self, session_id: &str) -> Result<(), CliError> {
1100 tracing::info!("Session load command detected for session: {}", session_id);
1101
1102 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 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 sessions
1125 .iter()
1126 .find(|s| s.id == session_id)
1127 .or_else(|| sessions.iter().find(|s| s.id.starts_with(session_id)))
1129 } else {
1130 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 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 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 if let Ok(mut messages_guard) = self.tui_bridge.messages.try_lock() {
1166 *messages_guard = messages.clone();
1167 }
1168
1169 if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1171 chat.clear();
1172
1173 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 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 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 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 #[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 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) } else {
1288 0
1289 };
1290
1291 let constraints: Vec<Constraint> = vec![Constraint::Percentage(90)]; let mut constraints = constraints;
1294 if activity_height > 0 {
1295 constraints.push(Constraint::Length(activity_height)); }
1297 constraints.push(Constraint::Length(1)); constraints.push(Constraint::Length(6)); 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 {
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 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 {
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 {
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 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 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); }
1474
1475 #[test]
1476 fn test_tui_state_default() {
1477 let state = TuiState::default();
1478 assert_eq!(state, TuiState::Idle);
1479 }
1480}