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::debug!("✓ Clipboard initialized successfully");
71 tracing::info!("Clipboard initialized successfully");
72 Some(Arc::new(Mutex::new(cb)))
73 }
74 Err(e) => {
75 tracing::debug!("✗ Clipboard initialization failed: {}", e);
76 tracing::warn!("Clipboard unavailable: {}", e);
77 None
78 }
79 };
80
81 let working_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
83 let autocomplete_manager = FileAutocompleteManager::new(working_dir);
84
85 let command_registry = crate::tui::commands::create_default_registry();
87
88 Ok(Self {
89 tui_bridge,
90 terminal,
91 running: true,
92 input_editor,
93 history_path,
94 status_message: "Ready - Type a message and press Enter".to_string(),
95 status_is_error: false,
96 mouse_selection_start: None,
97 clipboard,
98 autocomplete_manager,
99 cancellation_token: None,
100 input_handler: InputHandler::new(),
101 command_registry,
102 })
103 }
104
105 pub fn run(&mut self) -> Result<(), CliError> {
107 execute!(std::io::stdout(), EnterAlternateScreen)
109 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
110
111 execute!(std::io::stdout(), EnableMouseCapture)
113 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
114
115 execute!(std::io::stdout(), EnableBracketedPaste)
117 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
118
119 crossterm::terminal::enable_raw_mode()
120 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
121
122 struct AlternateScreenGuard;
124 impl Drop for AlternateScreenGuard {
125 fn drop(&mut self) {
126 let _ = crossterm::terminal::disable_raw_mode();
127 let _ = execute!(std::io::stdout(), DisableBracketedPaste);
128 let _ = execute!(std::io::stdout(), DisableMouseCapture);
129 let _ = execute!(std::io::stdout(), LeaveAlternateScreen);
130 }
131 }
132 let _guard = AlternateScreenGuard;
133
134 self.run_inner()
135 }
136
137 fn run_inner(&mut self) -> Result<(), CliError> {
138 while self.running {
139 self.tui_bridge.process_events()?;
141
142 if matches!(self.tui_bridge.state(), TuiState::Thinking) {
144 self.tui_bridge.tick_spinner();
145 }
146
147 self.update_status();
149
150 if event::poll(Duration::from_millis(100))
152 .map_err(|e| CliError::IoError(io::Error::other(e)))?
153 {
154 match event::read().map_err(|e| CliError::IoError(io::Error::other(e)))? {
155 Event::Key(key) => {
156 if key.kind == KeyEventKind::Press {
157 self.handle_key_event(key)?;
158 }
159 }
160 Event::Mouse(mouse) => {
161 match mouse.kind {
162 MouseEventKind::Down(MouseButton::Left) => {
163 self.mouse_selection_start = Some((mouse.column, mouse.row));
164 let chat = self.tui_bridge.chat_view().lock().unwrap();
166 if let Some((msg_idx, char_offset)) =
167 chat.screen_to_text_pos(mouse.column, mouse.row)
168 {
169 drop(chat);
170 self.tui_bridge
171 .chat_view()
172 .lock()
173 .unwrap()
174 .start_selection(msg_idx, char_offset);
175 } else {
176 drop(chat);
177 self.tui_bridge
178 .chat_view()
179 .lock()
180 .unwrap()
181 .clear_selection();
182 }
183 }
184 MouseEventKind::Drag(MouseButton::Left) => {
185 if self.mouse_selection_start.is_some() {
186 let chat = self.tui_bridge.chat_view().lock().unwrap();
188 if let Some((msg_idx, char_offset)) =
189 chat.screen_to_text_pos(mouse.column, mouse.row)
190 {
191 drop(chat);
192 self.tui_bridge
193 .chat_view()
194 .lock()
195 .unwrap()
196 .extend_selection(msg_idx, char_offset);
197 }
198 }
199 }
200 MouseEventKind::Up(MouseButton::Left) => {
201 self.mouse_selection_start = None;
202 }
203 MouseEventKind::ScrollUp => {
204 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
205 chat.scroll_up();
206 }
207 MouseEventKind::ScrollDown => {
208 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
209 chat.scroll_down();
210 }
211 _ => {}
212 }
213 }
214 Event::Paste(pasted) => {
215 if !self.tui_bridge.is_busy() {
216 self.insert_paste(&pasted);
217 }
218 }
219 _ => {}
220 }
221 } else {
222 self.tick_cursor_blink();
224 }
225
226 self.draw()?;
228 }
229
230 if let Err(e) = self.tui_bridge.save_session() {
232 tracing::error!("Failed to save session: {}", e);
233 }
234
235 if let Err(e) = self.input_editor.save_history(&self.history_path) {
237 tracing::error!("Failed to save input history: {}", e);
238 }
239
240 Ok(())
241 }
242
243 fn update_status(&mut self) {
244 let session_id = self.tui_bridge.session_id();
245 let has_activity = self
246 .tui_bridge
247 .activity_feed()
248 .lock()
249 .unwrap()
250 .has_in_progress();
251
252 match self.tui_bridge.state() {
253 TuiState::Idle => {
254 if has_activity {
255 let spinner = self.tui_bridge.spinner().lock().unwrap();
257 self.status_message = format!("{} Processing...", spinner.current_frame());
258 } else {
259 self.status_message = format!(
260 "Ready | Session: {}",
261 session_id.chars().take(8).collect::<String>()
262 );
263 }
264 self.status_is_error = false;
265 }
266 TuiState::Thinking => {
267 let spinner = self.tui_bridge.spinner().lock().unwrap();
268 self.status_message = format!("{} Thinking...", spinner.current_frame());
269 self.status_is_error = false;
270 }
271 }
272 }
273
274 fn insert_paste(&mut self, text: &str) {
276 let truncated = self.input_editor.insert_paste(text);
277 if truncated {
278 self.status_message = "Paste truncated (too large)".to_string();
279 self.status_is_error = true;
280 }
281 }
282
283 fn is_copy_paste_modifier(&self, key: &KeyEvent, char: char) -> bool {
287 #[cfg(target_os = "macos")]
288 {
289 let has_super = key.modifiers.contains(KeyModifiers::SUPER);
291 let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
292 let result = key.code == KeyCode::Char(char) && (has_super || has_ctrl);
293 tracing::debug!("is_copy_paste_modifier('{}') macOS: code={:?}, mod={:?}, super={}, ctrl={}, result={}",
294 char, key.code, key.modifiers, has_super, has_ctrl, result);
295 result
296 }
297 #[cfg(not(target_os = "macos"))]
298 {
299 let result =
300 key.code == KeyCode::Char(char) && key.modifiers.contains(KeyModifiers::CONTROL);
301 tracing::debug!(
302 "is_copy_paste_modifier('{}') non-macOS: code={:?}, mod={:?}, ctrl={:?}, result={}",
303 char,
304 key.code,
305 key.modifiers,
306 KeyModifiers::CONTROL,
307 result
308 );
309 result
310 }
311 }
312
313 fn tick_cursor_blink(&mut self) {
314 self.input_handler.tick_cursor_blink();
316 }
317
318 fn handle_key_event(&mut self, key: KeyEvent) -> Result<(), CliError> {
319 if self.is_copy_paste_modifier(&key, 'c') {
321 tracing::debug!("✓ Copy shortcut CONFIRMED - processing...");
322 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
323 let has_selection = chat.has_selection();
324 tracing::debug!("has_selection={}", has_selection);
325
326 if has_selection {
327 if let Some(selected) = chat.get_selected_text() {
328 tracing::debug!("Selected text length={}", selected.len());
329 if !selected.is_empty() {
330 if let Some(ref clipboard) = self.clipboard {
331 tracing::debug!("Attempting to copy to clipboard...");
332 match clipboard.lock().unwrap().set_text(&selected) {
333 Ok(()) => {
334 tracing::debug!("✓ Clipboard copy successful");
335 self.status_message = "Copied to clipboard".to_string();
336 self.status_is_error = false;
337 }
338 Err(e) => {
339 tracing::debug!("✗ Clipboard copy failed: {}", e);
340 self.status_message = format!("Clipboard error: {}", e);
341 self.status_is_error = true;
342 }
343 }
344 } else {
345 tracing::debug!("✗ Clipboard not available (None)");
346 self.status_message = "Clipboard not available".to_string();
347 self.status_is_error = true;
348 }
349 } else {
350 tracing::debug!("Selected text is empty");
351 }
352 chat.clear_selection();
353 } else {
354 tracing::debug!("get_selected_text() returned None");
355 }
356 return Ok(());
357 }
358
359 tracing::debug!("Ctrl/Cmd+C with no selection - ignoring");
361 return Ok(());
362 }
363
364 if self.is_copy_paste_modifier(&key, 'v') && !self.tui_bridge.is_busy() {
366 tracing::debug!("✓ Paste shortcut CONFIRMED - processing...");
367 let clipboard_result = if let Some(ref clipboard) = self.clipboard {
368 tracing::debug!("Attempting to read from clipboard...");
369 Some(clipboard.lock().unwrap().get_text())
370 } else {
371 None
372 };
373
374 match clipboard_result {
375 Some(Ok(text)) if !text.is_empty() => {
376 tracing::debug!("Read {} chars from clipboard", text.len());
377 self.insert_paste(&text);
378 }
379 Some(Ok(_)) => {
380 tracing::debug!("Clipboard is empty");
381 }
382 Some(Err(e)) => {
383 tracing::debug!("✗ Failed to read clipboard: {}", e);
384 self.status_message = format!("Could not read clipboard: {}", e);
385 self.status_is_error = true;
386 }
387 None => {
388 tracing::debug!("✗ Clipboard not available (None)");
389 self.status_message = "Clipboard not available".to_string();
390 self.status_is_error = true;
391 }
392 }
393 return Ok(());
394 }
395
396 let autocomplete_active = self.autocomplete_manager.is_active();
398 tracing::debug!(
399 "Key handling: autocomplete_active={}, is_busy={}, history_len={}",
400 autocomplete_active,
401 self.tui_bridge.is_busy(),
402 self.input_editor.history().len()
403 );
404
405 if autocomplete_active {
406 match key.code {
407 KeyCode::Up => {
408 self.autocomplete_manager.navigate_up();
409 return Ok(());
410 }
411 KeyCode::Down => {
412 self.autocomplete_manager.navigate_down();
413 return Ok(());
414 }
415 KeyCode::Enter | KeyCode::Tab => {
416 self.accept_file_completion();
417 return Ok(());
418 }
419 KeyCode::Esc => {
420 self.autocomplete_manager.deactivate();
421 return Ok(());
422 }
423 _ => {}
424 }
425 }
426
427 if key.code == KeyCode::Esc {
429 if self.autocomplete_manager.is_active() {
431 self.autocomplete_manager.deactivate();
432 } else if self.tui_bridge.is_busy() {
433 let now = Instant::now();
435 let last_esc_time = self.input_handler.last_esc_time();
436 let should_cancel = if let Some(last_esc) = last_esc_time {
437 now.duration_since(last_esc) < Duration::from_millis(1000)
438 } else {
439 false
440 };
441
442 if should_cancel {
443 self.cancel_current_operation();
444 } else {
445 self.status_message = "Press ESC again to cancel".to_string();
447 self.status_is_error = false;
448 self.input_handler.set_last_esc_time(now);
449 }
450 } else {
451 tracing::debug!("Esc pressed, exiting");
452 self.running = false;
453 }
454 return Ok(());
455 }
456
457 let term_height = self.terminal.size().map(|s| s.height).unwrap_or(24);
460 let viewport_height = term_height
461 .saturating_sub(1) .saturating_sub(7); match key.code {
464 KeyCode::PageUp => {
465 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
466 chat.scroll_page_up(viewport_height);
467 return Ok(());
468 }
469 KeyCode::PageDown => {
470 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
471 chat.scroll_page_down(viewport_height);
472 return Ok(());
473 }
474 KeyCode::Up => {
475 tracing::debug!(
477 "Up arrow: navigating history up, history_len={}",
478 self.input_editor.history().len()
479 );
480 let navigated = self.input_editor.navigate_history_up();
481 tracing::debug!(
482 "Up arrow: navigated={}, text='{}'",
483 navigated,
484 self.input_editor.text()
485 );
486 return Ok(());
487 }
488 KeyCode::Down => {
489 tracing::debug!(
491 "Down arrow: navigating history down, is_navigating={}",
492 self.input_editor.is_navigating_history()
493 );
494 let navigated = self.input_editor.navigate_history_down();
495 tracing::debug!(
496 "Down arrow: navigated={}, text='{}'",
497 navigated,
498 self.input_editor.text()
499 );
500 return Ok(());
501 }
502 _ => {}
503 }
504
505 if self.tui_bridge.is_busy() {
507 tracing::debug!("Agent busy, ignoring");
508 return Ok(());
509 }
510
511 if self.handle_backspace(&key) {
513 tracing::debug!("Backspace handled, input: {:?}", self.input_editor.text());
514 return Ok(());
515 }
516
517 match key.code {
518 KeyCode::Delete => {
519 if self.input_editor.delete_char_at() {
520 tracing::debug!("Delete: input now: {:?}", self.input_editor.text());
521 }
522 }
523 KeyCode::Left => {
524 tracing::debug!(
525 "KeyCode::Left: has_pasted_content={}",
526 self.input_editor.has_pasted_content()
527 );
528 self.input_editor.move_left();
529 }
530 KeyCode::Right => {
531 tracing::debug!(
532 "KeyCode::Right: has_pasted_content={}",
533 self.input_editor.has_pasted_content()
534 );
535 self.input_editor.move_right();
536 }
537 KeyCode::Home => {
538 self.input_editor.move_to_start();
539 }
540 KeyCode::End => {
541 self.input_editor.move_to_end();
542 }
543 KeyCode::Enter => {
544 self.handle_enter()?;
546 }
547 KeyCode::Char(c)
549 if key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT =>
550 {
551 if c == '@' {
553 self.input_editor.insert_char('@');
555
556 self.activate_file_autocomplete();
558 } else if self.autocomplete_manager.is_active() {
559 self.autocomplete_manager.append_char(c);
561 self.input_editor.insert_char(c);
562 } else {
563 self.input_editor.insert_char(c);
565 }
566 }
567 _ => {
568 }
570 }
571
572 Ok(())
573 }
574
575 fn handle_backspace(&mut self, key: &KeyEvent) -> bool {
577 if key.code == KeyCode::Backspace {
579 tracing::debug!("Backspace detected via KeyCode::Backspace");
580 self.delete_char_before_cursor();
581 return true;
582 }
583
584 if key.code == KeyCode::Char('h') && key.modifiers == KeyModifiers::CONTROL {
586 tracing::debug!("Backspace detected via Ctrl+H");
587 self.delete_char_before_cursor();
588 return true;
589 }
590
591 if let KeyCode::Char(c) = key.code {
593 if c == '\x7f' || c == '\x08' {
594 tracing::debug!("Backspace detected via char code: {}", c as u8);
595 self.delete_char_before_cursor();
596 return true;
597 }
598 }
599
600 false
601 }
602
603 fn delete_char_before_cursor(&mut self) {
604 tracing::debug!(
605 "delete_char: cursor={}, len={}, input={:?}",
606 self.input_editor.cursor(),
607 self.input_editor.text().len(),
608 self.input_editor.text()
609 );
610
611 if self.autocomplete_manager.is_active() {
613 let should_close = self.autocomplete_manager.backspace();
614
615 if should_close
616 && self.input_editor.cursor() > 0
617 && self.input_editor.char_before_cursor() == Some('@')
618 {
619 self.input_editor.delete_char_before();
620 self.autocomplete_manager.deactivate();
621 return;
622 }
623 }
624
625 self.input_editor.delete_char_before();
627 }
628
629 fn activate_file_autocomplete(&mut self) {
631 let trigger_pos = self.input_editor.cursor() - 1; self.autocomplete_manager.activate(trigger_pos);
633
634 tracing::info!(
635 "🔍 ACTIVATED AUTOCOMPLETE: trigger_pos={}, cursor={}, text='{}'",
636 trigger_pos,
637 self.input_editor.cursor(),
638 self.input_editor.text()
639 );
640 }
641
642 fn accept_file_completion(&mut self) {
644 let selected = self.autocomplete_manager.selected_match().cloned();
647
648 if let Some(selected) = selected {
649 let trigger_pos = self.autocomplete_manager.trigger_pos().unwrap_or(0);
650 let current_cursor = self.input_editor.cursor();
651
652 tracing::info!(
653 "🎯 ACCEPT: trigger_pos={}, cursor={}, path='{}'",
654 trigger_pos,
655 current_cursor,
656 selected.path
657 );
658
659 let remove_start = trigger_pos + 1;
661 let remove_end = current_cursor;
662
663 tracing::info!(
664 "🎯 REMOVE RANGE: {}..{} = '{}'",
665 remove_start,
666 remove_end,
667 &self.input_editor.text()
668 [remove_start..remove_end.min(self.input_editor.text().len())]
669 );
670
671 if remove_end > remove_start {
674 self.input_editor
675 .replace_range(remove_start, remove_end, &selected.path);
676 } else {
677 self.input_editor.set_cursor(remove_start);
679 self.input_editor.insert_str(&selected.path);
680 }
681
682 self.input_editor.insert_char(' ');
684
685 tracing::info!(
686 "🎯 FINAL: '{}', cursor={}",
687 self.input_editor.text(),
688 self.input_editor.cursor()
689 );
690 }
691
692 self.autocomplete_manager.deactivate();
694 }
695
696 fn handle_enter(&mut self) -> Result<(), CliError> {
697 let text = self.input_editor.take_and_add_to_history();
698
699 if text.is_empty() {
700 return Ok(());
701 }
702
703 tracing::info!("Enter pressed with text: {:?}", text);
704
705 if text.starts_with('/') {
707 use crate::tui::commands::{CommandContext, CommandResult};
708
709 let mut cmd_ctx = CommandContext::new(
710 self.tui_bridge.chat_view().clone(),
711 self.tui_bridge.session_manager(),
712 self.tui_bridge.session_id(),
713 self.tui_bridge.state_arc(),
714 self.tui_bridge.messages(),
715 self.tui_bridge.total_input_tokens_arc(),
716 self.tui_bridge.total_output_tokens_arc(),
717 self.clipboard.clone(),
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 rt = tokio::runtime::Runtime::new().unwrap();
839
840 #[allow(clippy::await_holding_lock)]
842 rt.block_on(async {
843 if cancel_token.is_cancelled() {
845 tracing::debug!("Operation cancelled before acquiring locks");
846 return;
847 }
848
849 let messages_guard = {
851 let mut attempts = 0;
852 loop {
853 if cancel_token.is_cancelled() {
854 tracing::debug!("Operation cancelled while waiting for messages lock");
855 return;
856 }
857 match messages.try_lock() {
858 Ok(guard) => break guard,
859 Err(std::sync::TryLockError::WouldBlock) => {
860 attempts += 1;
861 if attempts > 50 {
862 tracing::error!("Timeout waiting for messages lock");
863 return;
864 }
865 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
866 }
867 Err(e) => {
868 tracing::error!("Failed to lock messages: {}", e);
869 return;
870 }
871 }
872 }
873 };
874
875 let mut messages_guard = messages_guard;
876
877 if cancel_token.is_cancelled() {
879 tracing::debug!("Operation cancelled before acquiring bridge lock");
880 return;
881 }
882
883 let bridge_guard = {
884 let mut attempts = 0;
885 loop {
886 if cancel_token.is_cancelled() {
887 tracing::debug!("Operation cancelled while waiting for bridge lock");
888 return;
889 }
890 match agent_bridge.try_lock() {
891 Ok(guard) => break guard,
892 Err(std::sync::TryLockError::WouldBlock) => {
893 attempts += 1;
894 if attempts > 50 {
895 tracing::error!("Timeout waiting for bridge lock");
896 return;
897 }
898 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
899 }
900 Err(e) => {
901 tracing::error!("Failed to lock agent_bridge: {}", e);
902 return;
903 }
904 }
905 }
906 };
907
908 let mut bridge = bridge_guard;
909
910 bridge.set_cancellation_token(cancel_token.clone(), operation_id);
912
913 match bridge.process_message(&text, &mut messages_guard).await {
914 Ok(_response) => {
915 let msgs = messages_guard.clone();
921 let input_tokens = *total_input_tokens.lock().unwrap();
922 let output_tokens = *total_output_tokens.lock().unwrap();
923
924 if let Err(e) = session_manager.lock().unwrap().save_session(
925 &session_id,
926 &msgs,
927 input_tokens,
928 output_tokens,
929 ) {
930 tracing::error!("✗ Failed to auto-save session {}: {}", session_id, e);
931 } else {
932 tracing::info!(
933 "✓ Session {} auto-saved ({} messages, {} in, {} out tokens)",
934 session_id,
935 msgs.len(),
936 input_tokens,
937 output_tokens
938 );
939 }
940 }
941 Err(e) => {
942 let error_msg = e.to_string();
944 if error_msg.contains("cancelled") {
945 tracing::info!("Request cancelled by user");
946 } else {
947 tracing::error!("LLM error: {}", e);
948 }
949 }
950 }
951
952 bridge.clear_cancellation_token();
954 });
955 });
956
957 Ok(())
958 }
959
960 fn cancel_current_operation(&mut self) {
962 if let Some(ref token) = self.cancellation_token {
963 token.cancel();
964 tracing::debug!("Cancellation token triggered");
965
966 self.tui_bridge.next_operation_id();
968
969 self.tui_bridge.set_state(TuiState::Idle);
971
972 self.status_message = "Operation cancelled".to_string();
974 self.status_is_error = false;
975
976 self.tui_bridge
978 .activity_feed()
979 .lock()
980 .unwrap()
981 .complete_all();
982
983 let cancel_msg = Message::system("⚠ Operation cancelled by user".to_string());
985 self.tui_bridge
986 .chat_view()
987 .lock()
988 .unwrap()
989 .add_message(cancel_msg);
990 }
991 self.cancellation_token = None;
992 self.input_handler.reset_esc_time();
994 }
995
996 fn draw(&mut self) -> Result<(), CliError> {
997 let chat_view = self.tui_bridge.chat_view().clone();
998 let display_text = self.input_editor.display_text_combined();
999 let cursor_pos = self.input_editor.cursor();
1000 let cursor_blink_state = self.input_handler.cursor_blink_state();
1001 let tui_bridge = &self.tui_bridge;
1002 let file_autocomplete = self.autocomplete_manager.to_legacy_state();
1003
1004 self.terminal
1005 .draw(|f| {
1006 UiRenderer::render(
1007 f,
1008 f.area(),
1009 &chat_view,
1010 &display_text,
1011 cursor_pos,
1012 &self.status_message,
1013 self.status_is_error,
1014 cursor_blink_state,
1015 tui_bridge,
1016 &file_autocomplete,
1017 );
1018 })
1019 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
1020
1021 Ok(())
1022 }
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027 use super::*;
1028 use crate::agent_bridge::AgentBridge;
1029 use crate::tui::bridge::TuiBridge;
1030 use std::io::IsTerminal;
1031 use tokio::sync::mpsc;
1032
1033 fn create_test_config() -> limit_llm::Config {
1035 use limit_llm::{BrowserConfigSection, ProviderConfig};
1036 let mut providers = std::collections::HashMap::new();
1037 providers.insert(
1038 "anthropic".to_string(),
1039 ProviderConfig {
1040 api_key: Some("test-key".to_string()),
1041 model: "claude-3-5-sonnet-20241022".to_string(),
1042 base_url: None,
1043 max_tokens: 4096,
1044 timeout: 60,
1045 max_iterations: 100,
1046 thinking_enabled: false,
1047 clear_thinking: true,
1048 },
1049 );
1050 limit_llm::Config {
1051 provider: "anthropic".to_string(),
1052 providers,
1053 browser: BrowserConfigSection::default(),
1054 }
1055 }
1056
1057 #[test]
1058 fn test_tui_app_new() {
1059 if std::io::stdout().is_terminal() {
1061 let config = create_test_config();
1062 let agent_bridge = AgentBridge::new(config).unwrap();
1063 let (_tx, rx) = mpsc::unbounded_channel();
1064
1065 let tui_bridge = TuiBridge::new(agent_bridge, rx).unwrap();
1066 let app = TuiApp::new(tui_bridge);
1067 assert!(app.is_ok());
1068 }
1069 }
1070}