1use crate::clipboard::ClipboardManager;
6use crate::error::CliError;
7use crate::tui::autocomplete::FileAutocompleteManager;
8use crate::tui::bridge::TuiBridge;
9use crate::tui::input::{InputEditor, InputHandler};
10use crate::tui::ui::UiRenderer;
11use crate::tui::TuiState;
12use crossterm::event::{
13 self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
14 Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind,
15};
16use crossterm::execute;
17use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
18use limit_tui::components::Message;
19use ratatui::{backend::CrosstermBackend, Terminal};
20use std::io;
21use std::sync::{Arc, Mutex};
22use std::time::{Duration, Instant};
23
24pub struct TuiApp {
25 tui_bridge: TuiBridge,
26 terminal: Terminal<CrosstermBackend<io::Stdout>>,
27 running: bool,
28 input_editor: InputEditor,
30 history_path: std::path::PathBuf,
32 status_message: String,
33 status_is_error: bool,
34 mouse_selection_start: Option<(u16, u16)>,
36 clipboard: Option<Arc<Mutex<ClipboardManager>>>,
38 autocomplete_manager: FileAutocompleteManager,
40 cancellation_token: Option<tokio_util::sync::CancellationToken>,
42 input_handler: InputHandler,
44 command_registry: crate::tui::commands::CommandRegistry,
46}
47
48impl TuiApp {
49 pub fn new(tui_bridge: TuiBridge) -> Result<Self, CliError> {
51 let backend = CrosstermBackend::new(io::stdout());
52 let terminal =
53 Terminal::new(backend).map_err(|e| CliError::IoError(io::Error::other(e)))?;
54
55 let session_id = tui_bridge.session_id();
56 tracing::info!("TUI started with session: {}", session_id);
57
58 let home_dir = dirs::home_dir()
60 .ok_or_else(|| CliError::ConfigError("Failed to get home directory".to_string()))?;
61 let limit_dir = home_dir.join(".limit");
62 let history_path = limit_dir.join("input_history.bin");
63
64 let input_editor = InputEditor::with_history(&history_path)
66 .map_err(|e| CliError::ConfigError(format!("Failed to load input history: {}", e)))?;
67
68 let clipboard = match ClipboardManager::new() {
69 Ok(cb) => {
70 tracing::info!("Clipboard initialized successfully");
71 Some(Arc::new(Mutex::new(cb)))
72 }
73 Err(e) => {
74 tracing::debug!("✗ Clipboard initialization failed: {}", e);
75 tracing::warn!("Clipboard unavailable: {}", e);
76 None
77 }
78 };
79
80 let working_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
82 let autocomplete_manager = FileAutocompleteManager::new(working_dir);
83
84 let command_registry = crate::tui::commands::create_default_registry();
86
87 Ok(Self {
88 tui_bridge,
89 terminal,
90 running: true,
91 input_editor,
92 history_path,
93 status_message: "Ready - Type a message and press Enter".to_string(),
94 status_is_error: false,
95 mouse_selection_start: None,
96 clipboard,
97 autocomplete_manager,
98 cancellation_token: None,
99 input_handler: InputHandler::new(),
100 command_registry,
101 })
102 }
103
104 pub fn run(&mut self) -> Result<(), CliError> {
106 execute!(std::io::stdout(), EnterAlternateScreen)
108 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
109
110 execute!(std::io::stdout(), EnableMouseCapture)
112 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
113
114 execute!(std::io::stdout(), EnableBracketedPaste)
116 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
117
118 crossterm::terminal::enable_raw_mode()
119 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
120
121 struct AlternateScreenGuard;
123 impl Drop for AlternateScreenGuard {
124 fn drop(&mut self) {
125 let _ = crossterm::terminal::disable_raw_mode();
126 let _ = execute!(std::io::stdout(), DisableBracketedPaste);
127 let _ = execute!(std::io::stdout(), DisableMouseCapture);
128 let _ = execute!(std::io::stdout(), LeaveAlternateScreen);
129 }
130 }
131 let _guard = AlternateScreenGuard;
132
133 self.run_inner()
134 }
135
136 fn run_inner(&mut self) -> Result<(), CliError> {
137 while self.running {
138 self.tui_bridge.process_events()?;
140
141 if matches!(self.tui_bridge.state(), TuiState::Thinking) {
143 self.tui_bridge.tick_spinner();
144 }
145
146 self.update_status();
148
149 if event::poll(Duration::from_millis(100))
151 .map_err(|e| CliError::IoError(io::Error::other(e)))?
152 {
153 match event::read().map_err(|e| CliError::IoError(io::Error::other(e)))? {
154 Event::Key(key) => {
155 if key.kind == KeyEventKind::Press {
156 self.handle_key_event(key)?;
157 }
158 }
159 Event::Mouse(mouse) => {
160 match mouse.kind {
161 MouseEventKind::Down(MouseButton::Left) => {
162 self.mouse_selection_start = Some((mouse.column, mouse.row));
163 let chat = self.tui_bridge.chat_view().lock().unwrap();
165 if let Some((msg_idx, char_offset)) =
166 chat.screen_to_text_pos(mouse.column, mouse.row)
167 {
168 drop(chat);
169 self.tui_bridge
170 .chat_view()
171 .lock()
172 .unwrap()
173 .start_selection(msg_idx, char_offset);
174 } else {
175 drop(chat);
176 self.tui_bridge
177 .chat_view()
178 .lock()
179 .unwrap()
180 .clear_selection();
181 }
182 }
183 MouseEventKind::Drag(MouseButton::Left) => {
184 if self.mouse_selection_start.is_some() {
185 let chat = self.tui_bridge.chat_view().lock().unwrap();
187 if let Some((msg_idx, char_offset)) =
188 chat.screen_to_text_pos(mouse.column, mouse.row)
189 {
190 drop(chat);
191 self.tui_bridge
192 .chat_view()
193 .lock()
194 .unwrap()
195 .extend_selection(msg_idx, char_offset);
196 }
197 }
198 }
199 MouseEventKind::Up(MouseButton::Left) => {
200 self.mouse_selection_start = None;
201 }
202 MouseEventKind::ScrollUp => {
203 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
204 chat.scroll_up();
205 }
206 MouseEventKind::ScrollDown => {
207 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
208 chat.scroll_down();
209 }
210 _ => {}
211 }
212 }
213 Event::Paste(pasted) => {
214 if !self.tui_bridge.is_busy() {
215 self.insert_paste(&pasted);
216 }
217 }
218 _ => {}
219 }
220 } else {
221 self.tick_cursor_blink();
223 }
224
225 self.draw()?;
227 }
228
229 if let Err(e) = self.tui_bridge.save_session() {
231 tracing::error!("Failed to save session: {}", e);
232 }
233
234 if let Err(e) = self.input_editor.save_history(&self.history_path) {
236 tracing::error!("Failed to save input history: {}", e);
237 }
238
239 Ok(())
240 }
241
242 fn update_status(&mut self) {
243 let session_id = self.tui_bridge.session_id();
244 let has_activity = self
245 .tui_bridge
246 .activity_feed()
247 .lock()
248 .unwrap()
249 .has_in_progress();
250
251 match self.tui_bridge.state() {
252 TuiState::Idle => {
253 if has_activity {
254 let spinner = self.tui_bridge.spinner().lock().unwrap();
256 self.status_message = format!("{} Processing...", spinner.current_frame());
257 } else {
258 self.status_message = format!(
259 "Ready | Session: {}",
260 session_id.chars().take(8).collect::<String>()
261 );
262 }
263 self.status_is_error = false;
264 }
265 TuiState::Thinking => {
266 let spinner = self.tui_bridge.spinner().lock().unwrap();
267 self.status_message = format!("{} Thinking...", spinner.current_frame());
268 self.status_is_error = false;
269 }
270 }
271 }
272
273 fn insert_paste(&mut self, text: &str) {
275 let truncated = self.input_editor.insert_paste(text);
276 if truncated {
277 self.status_message = "Paste truncated (too large)".to_string();
278 self.status_is_error = true;
279 }
280 }
281
282 fn is_copy_paste_modifier(&self, key: &KeyEvent, char: char) -> bool {
286 #[cfg(target_os = "macos")]
287 {
288 let has_super = key.modifiers.contains(KeyModifiers::SUPER);
290 let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
291 let result = key.code == KeyCode::Char(char) && (has_super || has_ctrl);
292 tracing::trace!("is_copy_paste_modifier('{}') macOS: code={:?}, mod={:?}, super={}, ctrl={}, result={}",
293 char, key.code, key.modifiers, has_super, has_ctrl, result);
294 result
295 }
296 #[cfg(not(target_os = "macos"))]
297 {
298 let result =
299 key.code == KeyCode::Char(char) && key.modifiers.contains(KeyModifiers::CONTROL);
300 tracing::trace!(
301 "is_copy_paste_modifier('{}') non-macOS: code={:?}, mod={:?}, ctrl={:?}, result={}",
302 char,
303 key.code,
304 key.modifiers,
305 KeyModifiers::CONTROL,
306 result
307 );
308 result
309 }
310 }
311
312 fn tick_cursor_blink(&mut self) {
313 self.input_handler.tick_cursor_blink();
315 }
316
317 fn handle_key_event(&mut self, key: KeyEvent) -> Result<(), CliError> {
318 if self.is_copy_paste_modifier(&key, 'c') {
320 tracing::trace!("✓ Copy shortcut CONFIRMED - processing...");
321 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
322 let has_selection = chat.has_selection();
323 tracing::trace!("has_selection={}", has_selection);
324
325 if has_selection {
326 if let Some(selected) = chat.get_selected_text() {
327 tracing::trace!("Selected text length={}", selected.len());
328 if !selected.is_empty() {
329 if let Some(ref clipboard) = self.clipboard {
330 tracing::trace!("Attempting to copy to clipboard...");
331 match clipboard.lock().unwrap().set_text(&selected) {
332 Ok(()) => {
333 tracing::trace!("✓ Clipboard copy successful");
334 self.status_message = "Copied to clipboard".to_string();
335 self.status_is_error = false;
336 }
337 Err(e) => {
338 tracing::debug!("✗ Clipboard copy failed: {}", e);
339 self.status_message = format!("Clipboard error: {}", e);
340 self.status_is_error = true;
341 }
342 }
343 } else {
344 tracing::debug!("✗ Clipboard not available (None)");
345 self.status_message = "Clipboard not available".to_string();
346 self.status_is_error = true;
347 }
348 } else {
349 tracing::trace!("Selected text is empty");
350 }
351 chat.clear_selection();
352 } else {
353 tracing::trace!("get_selected_text() returned None");
354 }
355 return Ok(());
356 }
357
358 tracing::trace!("Ctrl/Cmd+C with no selection - ignoring");
360 return Ok(());
361 }
362
363 if self.is_copy_paste_modifier(&key, 'v') && !self.tui_bridge.is_busy() {
365 tracing::trace!("✓ Paste shortcut CONFIRMED - processing...");
366 let clipboard_result = if let Some(ref clipboard) = self.clipboard {
367 tracing::trace!("Attempting to read from clipboard...");
368 Some(clipboard.lock().unwrap().get_text())
369 } else {
370 None
371 };
372
373 match clipboard_result {
374 Some(Ok(text)) if !text.is_empty() => {
375 tracing::trace!("Read {} chars from clipboard", text.len());
376 self.insert_paste(&text);
377 }
378 Some(Ok(_)) => {
379 tracing::trace!("Clipboard is empty");
380 }
381 Some(Err(e)) => {
382 tracing::debug!("✗ Failed to read clipboard: {}", e);
383 self.status_message = format!("Could not read clipboard: {}", e);
384 self.status_is_error = true;
385 }
386 None => {
387 tracing::debug!("✗ Clipboard not available (None)");
388 self.status_message = "Clipboard not available".to_string();
389 self.status_is_error = true;
390 }
391 }
392 return Ok(());
393 }
394
395 let autocomplete_active = self.autocomplete_manager.is_active();
397 tracing::trace!(
398 "Key handling: autocomplete_active={}, is_busy={}, history_len={}",
399 autocomplete_active,
400 self.tui_bridge.is_busy(),
401 self.input_editor.history().len()
402 );
403
404 if autocomplete_active {
405 match key.code {
406 KeyCode::Up => {
407 self.autocomplete_manager.navigate_up();
408 return Ok(());
409 }
410 KeyCode::Down => {
411 self.autocomplete_manager.navigate_down();
412 return Ok(());
413 }
414 KeyCode::Enter | KeyCode::Tab => {
415 self.accept_file_completion();
416 return Ok(());
417 }
418 KeyCode::Esc => {
419 self.autocomplete_manager.deactivate();
420 return Ok(());
421 }
422 _ => {}
423 }
424 }
425
426 if key.code == KeyCode::Esc {
428 if self.autocomplete_manager.is_active() {
430 self.autocomplete_manager.deactivate();
431 } else if self.tui_bridge.is_busy() {
432 let now = Instant::now();
434 let last_esc_time = self.input_handler.last_esc_time();
435 let should_cancel = if let Some(last_esc) = last_esc_time {
436 now.duration_since(last_esc) < Duration::from_millis(1000)
437 } else {
438 false
439 };
440
441 if should_cancel {
442 self.cancel_current_operation();
443 } else {
444 self.status_message = "Press ESC again to cancel".to_string();
446 self.status_is_error = false;
447 self.input_handler.set_last_esc_time(now);
448 }
449 } else {
450 tracing::debug!("Esc pressed, exiting");
451 self.running = false;
452 }
453 return Ok(());
454 }
455
456 let term_height = self.terminal.size().map(|s| s.height).unwrap_or(24);
459 let viewport_height = term_height
460 .saturating_sub(1) .saturating_sub(7); match key.code {
463 KeyCode::PageUp => {
464 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
465 chat.scroll_page_up(viewport_height);
466 return Ok(());
467 }
468 KeyCode::PageDown => {
469 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
470 chat.scroll_page_down(viewport_height);
471 return Ok(());
472 }
473 KeyCode::Up => {
474 tracing::debug!(
476 "Up arrow: navigating history up, history_len={}",
477 self.input_editor.history().len()
478 );
479 let navigated = self.input_editor.navigate_history_up();
480 tracing::debug!(
481 "Up arrow: navigated={}, text='{}'",
482 navigated,
483 self.input_editor.text()
484 );
485 return Ok(());
486 }
487 KeyCode::Down => {
488 tracing::debug!(
490 "Down arrow: navigating history down, is_navigating={}",
491 self.input_editor.is_navigating_history()
492 );
493 let navigated = self.input_editor.navigate_history_down();
494 tracing::debug!(
495 "Down arrow: navigated={}, text='{}'",
496 navigated,
497 self.input_editor.text()
498 );
499 return Ok(());
500 }
501 _ => {}
502 }
503
504 if self.tui_bridge.is_busy() {
506 tracing::debug!("Agent busy, ignoring");
507 return Ok(());
508 }
509
510 if self.handle_backspace(&key) {
512 tracing::debug!("Backspace handled, input: {:?}", self.input_editor.text());
513 return Ok(());
514 }
515
516 match key.code {
517 KeyCode::Delete => {
518 if self.input_editor.delete_char_at() {
519 tracing::debug!("Delete: input now: {:?}", self.input_editor.text());
520 }
521 }
522 KeyCode::Left => {
523 tracing::debug!(
524 "KeyCode::Left: has_pasted_content={}",
525 self.input_editor.has_pasted_content()
526 );
527 self.input_editor.move_left();
528 }
529 KeyCode::Right => {
530 tracing::debug!(
531 "KeyCode::Right: has_pasted_content={}",
532 self.input_editor.has_pasted_content()
533 );
534 self.input_editor.move_right();
535 }
536 KeyCode::Home => {
537 self.input_editor.move_to_start();
538 }
539 KeyCode::End => {
540 self.input_editor.move_to_end();
541 }
542 KeyCode::Enter => {
543 self.handle_enter()?;
545 }
546 KeyCode::Char(c)
548 if key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT =>
549 {
550 if c == '@' {
552 self.input_editor.insert_char('@');
554
555 self.activate_file_autocomplete();
557 } else if self.autocomplete_manager.is_active() {
558 self.autocomplete_manager.append_char(c);
560 self.input_editor.insert_char(c);
561 } else {
562 self.input_editor.insert_char(c);
564 }
565 }
566 _ => {
567 }
569 }
570
571 Ok(())
572 }
573
574 fn handle_backspace(&mut self, key: &KeyEvent) -> bool {
576 if key.code == KeyCode::Backspace {
578 tracing::debug!("Backspace detected via KeyCode::Backspace");
579 self.delete_char_before_cursor();
580 return true;
581 }
582
583 if key.code == KeyCode::Char('h') && key.modifiers == KeyModifiers::CONTROL {
585 tracing::debug!("Backspace detected via Ctrl+H");
586 self.delete_char_before_cursor();
587 return true;
588 }
589
590 if let KeyCode::Char(c) = key.code {
592 if c == '\x7f' || c == '\x08' {
593 tracing::debug!("Backspace detected via char code: {}", c as u8);
594 self.delete_char_before_cursor();
595 return true;
596 }
597 }
598
599 false
600 }
601
602 fn delete_char_before_cursor(&mut self) {
603 tracing::debug!(
604 "delete_char: cursor={}, len={}, input={:?}",
605 self.input_editor.cursor(),
606 self.input_editor.text().len(),
607 self.input_editor.text()
608 );
609
610 if self.autocomplete_manager.is_active() {
612 let should_close = self.autocomplete_manager.backspace();
613
614 if should_close
615 && self.input_editor.cursor() > 0
616 && self.input_editor.char_before_cursor() == Some('@')
617 {
618 self.input_editor.delete_char_before();
619 self.autocomplete_manager.deactivate();
620 return;
621 }
622 }
623
624 self.input_editor.delete_char_before();
626 }
627
628 fn activate_file_autocomplete(&mut self) {
630 let trigger_pos = self.input_editor.cursor() - 1; self.autocomplete_manager.activate(trigger_pos);
632
633 tracing::info!(
634 "🔍 ACTIVATED AUTOCOMPLETE: trigger_pos={}, cursor={}, text='{}'",
635 trigger_pos,
636 self.input_editor.cursor(),
637 self.input_editor.text()
638 );
639 }
640
641 fn accept_file_completion(&mut self) {
643 let selected = self.autocomplete_manager.selected_match().cloned();
646
647 if let Some(selected) = selected {
648 let trigger_pos = self.autocomplete_manager.trigger_pos().unwrap_or(0);
649 let current_cursor = self.input_editor.cursor();
650
651 tracing::info!(
652 "🎯 ACCEPT: trigger_pos={}, cursor={}, path='{}'",
653 trigger_pos,
654 current_cursor,
655 selected.path
656 );
657
658 let remove_start = trigger_pos + 1;
660 let remove_end = current_cursor;
661
662 tracing::info!(
663 "🎯 REMOVE RANGE: {}..{} = '{}'",
664 remove_start,
665 remove_end,
666 &self.input_editor.text()
667 [remove_start..remove_end.min(self.input_editor.text().len())]
668 );
669
670 if remove_end > remove_start {
673 self.input_editor
674 .replace_range(remove_start, remove_end, &selected.path);
675 } else {
676 self.input_editor.set_cursor(remove_start);
678 self.input_editor.insert_str(&selected.path);
679 }
680
681 self.input_editor.insert_char(' ');
683
684 tracing::info!(
685 "🎯 FINAL: '{}', cursor={}",
686 self.input_editor.text(),
687 self.input_editor.cursor()
688 );
689 }
690
691 self.autocomplete_manager.deactivate();
693 }
694
695 fn handle_enter(&mut self) -> Result<(), CliError> {
696 let text = self.input_editor.take_and_add_to_history();
697
698 if text.is_empty() {
699 return Ok(());
700 }
701
702 tracing::info!("Enter pressed with text: {:?}", text);
703
704 if text.starts_with('/') {
706 use crate::tui::commands::{CommandContext, CommandResult};
707
708 let mut cmd_ctx = CommandContext::new(
709 self.tui_bridge.chat_view().clone(),
710 self.tui_bridge.session_manager(),
711 self.tui_bridge.session_id(),
712 self.tui_bridge.state_arc(),
713 self.tui_bridge.messages(),
714 self.tui_bridge.total_input_tokens_arc(),
715 self.tui_bridge.total_output_tokens_arc(),
716 self.clipboard.clone(),
717 self.autocomplete_manager.base_path().to_path_buf(),
718 );
719
720 match self.command_registry.parse_and_execute(&text, &mut cmd_ctx) {
722 Ok(Some(result)) => {
723 self.tui_bridge
725 .session_id_arc()
726 .lock()
727 .unwrap()
728 .clone_from(&cmd_ctx.session_id);
729
730 match result {
731 CommandResult::Exit => {
732 self.running = false;
733 return Ok(());
734 }
735 CommandResult::ClearChat => {
736 return Ok(());
738 }
739 CommandResult::NewSession | CommandResult::LoadSession(_) => {
740 *self.tui_bridge.messages().lock().unwrap() =
742 cmd_ctx.messages.lock().unwrap().clone();
743 *self.tui_bridge.total_input_tokens_arc().lock().unwrap() =
744 *cmd_ctx.total_input_tokens.lock().unwrap();
745 *self.tui_bridge.total_output_tokens_arc().lock().unwrap() =
746 *cmd_ctx.total_output_tokens.lock().unwrap();
747 return Ok(());
748 }
749 CommandResult::Continue
750 | CommandResult::Message(_)
751 | CommandResult::Share(_) => {
752 return Ok(());
753 }
754 }
755 }
756 Ok(None) => {
757 }
759 Err(e) => {
760 tracing::error!("Command error: {}", e);
761 self.tui_bridge
763 .chat_view()
764 .lock()
765 .unwrap()
766 .add_message(Message::system(format!("Error: {}", e)));
767 return Ok(());
768 }
769 }
770 }
771
772 let text_lower = text.to_lowercase();
774 if text_lower == "exit" || text_lower == "quit" {
775 tracing::info!("Exit command detected, exiting");
776 self.running = false;
777 return Ok(());
778 }
779
780 if text_lower == "clear" {
781 tracing::info!("Clear command detected");
782 self.tui_bridge.chat_view().lock().unwrap().clear();
783 return Ok(());
784 }
785
786 if text_lower == "help" {
787 tracing::info!("Help command detected");
788 let help_msg = Message::system(
789 "Available commands:\n\
790 /help - Show this help message\n\
791 /clear - Clear chat history\n\
792 /exit - Exit the application\n\
793 /quit - Exit the application\n\
794 /session list - List all sessions\n\
795 /session new - Create a new session\n\
796 /session load <id> - Load a session by ID\n\
797 /share - Copy session to clipboard (markdown)\n\
798 /share md - Export session as markdown file\n\
799 /share json - Export session as JSON file\n\
800 \n\
801 Page Up/Down - Scroll chat history\n\
802 Up/Down (empty input) - Navigate input history"
803 .to_string(),
804 );
805 self.tui_bridge
806 .chat_view()
807 .lock()
808 .unwrap()
809 .add_message(help_msg);
810 return Ok(());
811 }
812
813 self.tui_bridge.add_user_message(text.clone());
815
816 let operation_id = self.tui_bridge.next_operation_id();
818 tracing::debug!("handle_enter: new operation_id={}", operation_id);
819 self.tui_bridge.set_state(TuiState::Idle);
820
821 let cancel_token = tokio_util::sync::CancellationToken::new();
823 self.cancellation_token = Some(cancel_token.clone());
824
825 let messages = self.tui_bridge.messages();
827 let agent_bridge = self.tui_bridge.agent_bridge_arc();
828 let session_manager = self.tui_bridge.session_manager();
829 let session_id = self.tui_bridge.session_id();
830 let total_input_tokens = self.tui_bridge.total_input_tokens_arc();
831 let total_output_tokens = self.tui_bridge.total_output_tokens_arc();
832
833 tracing::debug!("Spawning LLM processing thread");
834
835 std::thread::spawn(move || {
837 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
838 let rt = tokio::runtime::Runtime::new().unwrap();
840
841 #[allow(clippy::await_holding_lock)]
843 rt.block_on(async {
844 if cancel_token.is_cancelled() {
846 tracing::debug!("Operation cancelled before acquiring locks");
847 return;
848 }
849
850 let messages_guard = {
852 let mut attempts = 0;
853 loop {
854 if cancel_token.is_cancelled() {
855 tracing::debug!(
856 "Operation cancelled while waiting for messages lock"
857 );
858 return;
859 }
860 match messages.try_lock() {
861 Ok(guard) => break guard,
862 Err(std::sync::TryLockError::WouldBlock) => {
863 attempts += 1;
864 if attempts > 50 {
865 tracing::error!("Timeout waiting for messages lock");
866 return;
867 }
868 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
869 }
870 Err(e) => {
871 tracing::error!("Failed to lock messages: {}", e);
872 return;
873 }
874 }
875 }
876 };
877
878 let mut messages_guard = messages_guard;
879
880 if cancel_token.is_cancelled() {
882 tracing::debug!("Operation cancelled before acquiring bridge lock");
883 return;
884 }
885
886 let bridge_guard = {
887 let mut attempts = 0;
888 loop {
889 if cancel_token.is_cancelled() {
890 tracing::debug!(
891 "Operation cancelled while waiting for bridge lock"
892 );
893 return;
894 }
895 match agent_bridge.try_lock() {
896 Ok(guard) => break guard,
897 Err(std::sync::TryLockError::WouldBlock) => {
898 attempts += 1;
899 if attempts > 50 {
900 tracing::error!("Timeout waiting for bridge lock");
901 return;
902 }
903 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
904 }
905 Err(e) => {
906 tracing::error!("Failed to lock agent_bridge: {}", e);
907 return;
908 }
909 }
910 }
911 };
912
913 let mut bridge = bridge_guard;
914
915 bridge.set_cancellation_token(cancel_token.clone(), operation_id);
917
918 match bridge.process_message(&text, &mut messages_guard).await {
919 Ok(result) => {
920 {
921 let mut input = total_input_tokens.lock().unwrap();
922 let mut output = total_output_tokens.lock().unwrap();
923 *input += result.input_tokens;
924 *output += result.output_tokens;
925 }
926
927 let msgs = messages_guard.clone();
928 let input_tokens = *total_input_tokens.lock().unwrap();
929 let output_tokens = *total_output_tokens.lock().unwrap();
930
931 if let Err(e) = session_manager.lock().unwrap().save_session(
932 &session_id,
933 &msgs,
934 input_tokens,
935 output_tokens,
936 ) {
937 tracing::error!(
938 "✗ Failed to auto-save session {}: {}",
939 session_id,
940 e
941 );
942 } else {
943 tracing::info!(
944 "✓ Session {} auto-saved ({} messages, {} in, {} out tokens)",
945 session_id,
946 msgs.len(),
947 input_tokens,
948 output_tokens
949 );
950 }
951 }
952 Err(e) => {
953 let error_msg = e.to_string();
955 if error_msg.contains("cancelled") {
956 tracing::info!("Request cancelled by user");
957 } else {
958 tracing::error!("LLM error: {}", e);
959 }
960 }
961 }
962
963 bridge.clear_cancellation_token();
965 });
966 }));
967
968 if let Err(panic_payload) = result {
969 let msg = panic_payload
970 .downcast_ref::<&str>()
971 .copied()
972 .or_else(|| panic_payload.downcast_ref::<String>().map(|s| s.as_str()))
973 .unwrap_or("unknown panic");
974 tracing::error!("LLM thread panicked: {}", msg);
975 }
976 });
977
978 Ok(())
979 }
980
981 fn cancel_current_operation(&mut self) {
983 if let Some(ref token) = self.cancellation_token {
984 token.cancel();
985 tracing::debug!("Cancellation token triggered");
986
987 self.tui_bridge.next_operation_id();
989
990 self.tui_bridge.set_state(TuiState::Idle);
992
993 self.status_message = "Operation cancelled".to_string();
995 self.status_is_error = false;
996
997 self.tui_bridge
999 .activity_feed()
1000 .lock()
1001 .unwrap()
1002 .complete_all();
1003
1004 let cancel_msg = Message::system("⚠ Operation cancelled by user".to_string());
1006 self.tui_bridge
1007 .chat_view()
1008 .lock()
1009 .unwrap()
1010 .add_message(cancel_msg);
1011 }
1012 self.cancellation_token = None;
1013 self.input_handler.reset_esc_time();
1015 }
1016
1017 fn draw(&mut self) -> Result<(), CliError> {
1018 let chat_view = self.tui_bridge.chat_view().clone();
1019 let display_text = self.input_editor.display_text_combined();
1020 let cursor_pos = self.input_editor.cursor();
1021 let cursor_blink_state = self.input_handler.cursor_blink_state();
1022 let tui_bridge = &self.tui_bridge;
1023 let file_autocomplete = self.autocomplete_manager.to_legacy_state();
1024
1025 self.terminal
1026 .draw(|f| {
1027 UiRenderer::render(
1028 f,
1029 f.area(),
1030 &chat_view,
1031 &display_text,
1032 cursor_pos,
1033 &self.status_message,
1034 self.status_is_error,
1035 cursor_blink_state,
1036 tui_bridge,
1037 &file_autocomplete,
1038 );
1039 })
1040 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
1041
1042 Ok(())
1043 }
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048 use super::*;
1049 use crate::agent_bridge::AgentBridge;
1050 use crate::tui::bridge::TuiBridge;
1051 use std::io::IsTerminal;
1052 use tokio::sync::mpsc;
1053
1054 fn create_test_config() -> limit_llm::Config {
1056 use limit_llm::{BrowserConfigSection, ProviderConfig};
1057 let mut providers = std::collections::HashMap::new();
1058 providers.insert(
1059 "anthropic".to_string(),
1060 ProviderConfig {
1061 api_key: Some("test-key".to_string()),
1062 model: "claude-3-5-sonnet-20241022".to_string(),
1063 base_url: None,
1064 max_tokens: 4096,
1065 timeout: 60,
1066 max_iterations: 100,
1067 thinking_enabled: false,
1068 clear_thinking: true,
1069 },
1070 );
1071 limit_llm::Config {
1072 provider: "anthropic".to_string(),
1073 providers,
1074 browser: BrowserConfigSection::default(),
1075 compaction: limit_llm::CompactionSettings::default(),
1076 cache: limit_llm::CacheSettings::default(),
1077 }
1078 }
1079
1080 #[test]
1081 fn test_tui_app_new() {
1082 if std::io::stdout().is_terminal() {
1084 let config = create_test_config();
1085 let agent_bridge = AgentBridge::new(config).unwrap();
1086 let (_tx, rx) = mpsc::unbounded_channel();
1087
1088 let tui_bridge = TuiBridge::new(agent_bridge, rx).unwrap();
1089 let app = TuiApp::new(tui_bridge);
1090 assert!(app.is_ok());
1091 }
1092 }
1093}