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::time::{Duration, Instant};
22
23pub struct TuiApp {
24 tui_bridge: TuiBridge,
25 terminal: Terminal<CrosstermBackend<io::Stdout>>,
26 running: bool,
27 input_editor: InputEditor,
29 status_message: String,
30 status_is_error: bool,
31 mouse_selection_start: Option<(u16, u16)>,
33 clipboard: Option<ClipboardManager>,
35 autocomplete_manager: FileAutocompleteManager,
37 cancellation_token: Option<tokio_util::sync::CancellationToken>,
39 input_handler: InputHandler,
41 command_registry: crate::tui::commands::CommandRegistry,
43}
44
45impl TuiApp {
46 pub fn new(tui_bridge: TuiBridge) -> Result<Self, CliError> {
48 let backend = CrosstermBackend::new(io::stdout());
49 let terminal =
50 Terminal::new(backend).map_err(|e| CliError::IoError(io::Error::other(e)))?;
51
52 let session_id = tui_bridge.session_id();
53 tracing::info!("TUI started with session: {}", session_id);
54
55 let clipboard = match ClipboardManager::new() {
56 Ok(cb) => {
57 tracing::debug!("✓ Clipboard initialized successfully");
58 tracing::info!("Clipboard initialized successfully");
59 Some(cb)
60 }
61 Err(e) => {
62 tracing::debug!("✗ Clipboard initialization failed: {}", e);
63 tracing::warn!("Clipboard unavailable: {}", e);
64 None
65 }
66 };
67
68 let working_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
70 let autocomplete_manager = FileAutocompleteManager::new(working_dir);
71
72 let command_registry = crate::tui::commands::create_default_registry();
74
75 Ok(Self {
76 tui_bridge,
77 terminal,
78 running: true,
79 input_editor: InputEditor::new(),
80 status_message: "Ready - Type a message and press Enter".to_string(),
81 status_is_error: false,
82 mouse_selection_start: None,
83 clipboard,
84 autocomplete_manager,
85 cancellation_token: None,
86 input_handler: InputHandler::new(),
87 command_registry,
88 })
89 }
90
91 pub fn run(&mut self) -> Result<(), CliError> {
93 execute!(std::io::stdout(), EnterAlternateScreen)
95 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
96
97 execute!(std::io::stdout(), EnableMouseCapture)
99 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
100
101 execute!(std::io::stdout(), EnableBracketedPaste)
103 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
104
105 crossterm::terminal::enable_raw_mode()
106 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
107
108 struct AlternateScreenGuard;
110 impl Drop for AlternateScreenGuard {
111 fn drop(&mut self) {
112 let _ = crossterm::terminal::disable_raw_mode();
113 let _ = execute!(std::io::stdout(), DisableBracketedPaste);
114 let _ = execute!(std::io::stdout(), DisableMouseCapture);
115 let _ = execute!(std::io::stdout(), LeaveAlternateScreen);
116 }
117 }
118 let _guard = AlternateScreenGuard;
119
120 self.run_inner()
121 }
122
123 fn run_inner(&mut self) -> Result<(), CliError> {
124 while self.running {
125 self.tui_bridge.process_events()?;
127
128 if matches!(self.tui_bridge.state(), TuiState::Thinking) {
130 self.tui_bridge.tick_spinner();
131 }
132
133 self.update_status();
135
136 if event::poll(Duration::from_millis(100))
138 .map_err(|e| CliError::IoError(io::Error::other(e)))?
139 {
140 match event::read().map_err(|e| CliError::IoError(io::Error::other(e)))? {
141 Event::Key(key) => {
142 tracing::debug!(
144 "Event::Key - code={:?} mod={:?} kind={:?}",
145 key.code,
146 key.modifiers,
147 key.kind
148 );
149
150 if key.kind == KeyEventKind::Press {
151 self.handle_key_event(key)?;
152 }
153 }
154 Event::Mouse(mouse) => {
155 match mouse.kind {
156 MouseEventKind::Down(MouseButton::Left) => {
157 tracing::debug!("MouseDown at ({}, {})", mouse.column, mouse.row);
158 self.mouse_selection_start = Some((mouse.column, mouse.row));
159 let chat = self.tui_bridge.chat_view().lock().unwrap();
161 tracing::debug!(
162 " render_positions count: {}",
163 chat.render_position_count()
164 );
165 if let Some((msg_idx, char_offset)) =
166 chat.screen_to_text_pos(mouse.column, mouse.row)
167 {
168 tracing::debug!(
169 " -> Starting selection at msg={}, offset={}",
170 msg_idx,
171 char_offset
172 );
173 drop(chat);
174 self.tui_bridge
175 .chat_view()
176 .lock()
177 .unwrap()
178 .start_selection(msg_idx, char_offset);
179 } else {
180 tracing::debug!(" -> No match, clearing selection");
181 drop(chat);
182 self.tui_bridge
183 .chat_view()
184 .lock()
185 .unwrap()
186 .clear_selection();
187 }
188 }
189 MouseEventKind::Drag(MouseButton::Left) => {
190 if self.mouse_selection_start.is_some() {
191 let chat = self.tui_bridge.chat_view().lock().unwrap();
193 if let Some((msg_idx, char_offset)) =
194 chat.screen_to_text_pos(mouse.column, mouse.row)
195 {
196 drop(chat);
197 self.tui_bridge
198 .chat_view()
199 .lock()
200 .unwrap()
201 .extend_selection(msg_idx, char_offset);
202 }
203 }
204 }
205 MouseEventKind::Up(MouseButton::Left) => {
206 self.mouse_selection_start = None;
207 }
208 MouseEventKind::ScrollUp => {
209 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
210 chat.scroll_up();
211 }
212 MouseEventKind::ScrollDown => {
213 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
214 chat.scroll_down();
215 }
216 _ => {}
217 }
218 }
219 Event::Paste(pasted) => {
220 if !self.tui_bridge.is_busy() {
221 self.insert_paste(&pasted);
222 }
223 }
224 _ => {}
225 }
226 } else {
227 self.tick_cursor_blink();
229 }
230
231 self.draw()?;
233 }
234
235 if let Err(e) = self.tui_bridge.save_session() {
237 tracing::error!("Failed to save session: {}", 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 tracing::debug!(
321 "handle_key_event: code={:?} mod={:?} kind={:?}",
322 key.code,
323 key.modifiers,
324 key.kind
325 );
326
327 if matches!(key.code, KeyCode::Char('c') | KeyCode::Char('v')) {
329 tracing::debug!(
330 ">>> SPECIAL: '{}' key detected with modifiers: {:?} (SUPER={:?}, CONTROL={:?})",
331 if matches!(key.code, KeyCode::Char('c')) {
332 'c'
333 } else {
334 'v'
335 },
336 key.modifiers,
337 key.modifiers.contains(KeyModifiers::SUPER),
338 key.modifiers.contains(KeyModifiers::CONTROL)
339 );
340 }
341
342 if self.is_copy_paste_modifier(&key, 'c') {
344 tracing::debug!("✓ Copy shortcut CONFIRMED - processing...");
345 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
346 let has_selection = chat.has_selection();
347 tracing::debug!("has_selection={}", has_selection);
348
349 if has_selection {
350 if let Some(selected) = chat.get_selected_text() {
351 tracing::debug!("Selected text length={}", selected.len());
352 if !selected.is_empty() {
353 if let Some(ref clipboard) = self.clipboard {
354 tracing::debug!("Attempting to copy to clipboard...");
355 match clipboard.set_text(&selected) {
356 Ok(()) => {
357 tracing::debug!("✓ Clipboard copy successful");
358 self.status_message = "Copied to clipboard".to_string();
359 self.status_is_error = false;
360 }
361 Err(e) => {
362 tracing::debug!("✗ Clipboard copy failed: {}", e);
363 self.status_message = format!("Clipboard error: {}", e);
364 self.status_is_error = true;
365 }
366 }
367 } else {
368 tracing::debug!("✗ Clipboard not available (None)");
369 self.status_message = "Clipboard not available".to_string();
370 self.status_is_error = true;
371 }
372 } else {
373 tracing::debug!("Selected text is empty");
374 }
375 chat.clear_selection();
376 } else {
377 tracing::debug!("get_selected_text() returned None");
378 }
379 return Ok(());
380 }
381
382 tracing::debug!("Ctrl/Cmd+C with no selection - ignoring");
384 return Ok(());
385 }
386
387 if self.is_copy_paste_modifier(&key, 'v') && !self.tui_bridge.is_busy() {
389 tracing::debug!("✓ Paste shortcut CONFIRMED - processing...");
390 if let Some(ref clipboard) = self.clipboard {
391 tracing::debug!("Attempting to read from clipboard...");
392 match clipboard.get_text() {
393 Ok(text) if !text.is_empty() => {
394 tracing::debug!("Read {} chars from clipboard", text.len());
395 self.insert_paste(&text);
396 }
397 Ok(_) => {
398 tracing::debug!("Clipboard is empty");
399 } Err(e) => {
401 tracing::debug!("✗ Failed to read clipboard: {}", e);
402 self.status_message = format!("Could not read clipboard: {}", e);
403 self.status_is_error = true;
404 }
405 }
406 } else {
407 tracing::debug!("✗ Clipboard not available (None)");
408 self.status_message = "Clipboard not available".to_string();
409 self.status_is_error = true;
410 }
411 return Ok(());
412 }
413
414 if self.autocomplete_manager.is_active() {
416 match key.code {
417 KeyCode::Up => {
418 self.autocomplete_manager.navigate_up();
419 return Ok(());
420 }
421 KeyCode::Down => {
422 self.autocomplete_manager.navigate_down();
423 return Ok(());
424 }
425 KeyCode::Enter | KeyCode::Tab => {
426 self.accept_file_completion();
427 return Ok(());
428 }
429 KeyCode::Esc => {
430 self.autocomplete_manager.deactivate();
431 return Ok(());
432 }
433 _ => {}
434 }
435 }
436
437 if key.code == KeyCode::Esc {
439 if self.autocomplete_manager.is_active() {
441 self.autocomplete_manager.deactivate();
442 } else if self.tui_bridge.is_busy() {
443 let now = Instant::now();
445 let last_esc_time = self.input_handler.last_esc_time();
446 let should_cancel = if let Some(last_esc) = last_esc_time {
447 now.duration_since(last_esc) < Duration::from_millis(1000)
448 } else {
449 false
450 };
451
452 if should_cancel {
453 self.cancel_current_operation();
454 } else {
455 self.status_message = "Press ESC again to cancel".to_string();
457 self.status_is_error = false;
458 self.input_handler.set_last_esc_time(now);
459 }
460 } else {
461 tracing::debug!("Esc pressed, exiting");
462 self.running = false;
463 }
464 return Ok(());
465 }
466
467 let term_height = self.terminal.size().map(|s| s.height).unwrap_or(24);
470 let viewport_height = term_height
471 .saturating_sub(1) .saturating_sub(7); match key.code {
474 KeyCode::PageUp => {
475 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
476 chat.scroll_page_up(viewport_height);
477 return Ok(());
478 }
479 KeyCode::PageDown => {
480 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
481 chat.scroll_page_down(viewport_height);
482 return Ok(());
483 }
484 KeyCode::Up => {
485 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
486 chat.scroll_up();
487 return Ok(());
488 }
489 KeyCode::Down => {
490 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
492 chat.scroll_down();
493 return Ok(());
494 }
495 _ => {}
496 }
497
498 if self.tui_bridge.is_busy() {
500 tracing::debug!("Agent busy, ignoring");
501 return Ok(());
502 }
503
504 if self.handle_backspace(&key) {
506 tracing::debug!("Backspace handled, input: {:?}", self.input_editor.text());
507 return Ok(());
508 }
509
510 match key.code {
511 KeyCode::Delete => {
512 if self.input_editor.delete_char_at() {
513 tracing::debug!("Delete: input now: {:?}", self.input_editor.text());
514 }
515 }
516 KeyCode::Left => {
517 self.input_editor.move_left();
518 }
519 KeyCode::Right => {
520 self.input_editor.move_right();
521 }
522 KeyCode::Home => {
523 self.input_editor.move_to_start();
524 }
525 KeyCode::End => {
526 self.input_editor.move_to_end();
527 }
528 KeyCode::Enter => {
529 self.handle_enter()?;
531 }
532 KeyCode::Char(c)
534 if key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT =>
535 {
536 if c == '@' {
538 self.input_editor.insert_char('@');
540
541 self.activate_file_autocomplete();
543 } else if self.autocomplete_manager.is_active() {
544 self.autocomplete_manager.append_char(c);
546 self.input_editor.insert_char(c);
547 } else {
548 self.input_editor.insert_char(c);
550 }
551 }
552 _ => {
553 }
555 }
556
557 Ok(())
558 }
559
560 fn handle_backspace(&mut self, key: &KeyEvent) -> bool {
562 if key.code == KeyCode::Backspace {
564 tracing::debug!("Backspace detected via KeyCode::Backspace");
565 self.delete_char_before_cursor();
566 return true;
567 }
568
569 if key.code == KeyCode::Char('h') && key.modifiers == KeyModifiers::CONTROL {
571 tracing::debug!("Backspace detected via Ctrl+H");
572 self.delete_char_before_cursor();
573 return true;
574 }
575
576 if let KeyCode::Char(c) = key.code {
578 if c == '\x7f' || c == '\x08' {
579 tracing::debug!("Backspace detected via char code: {}", c as u8);
580 self.delete_char_before_cursor();
581 return true;
582 }
583 }
584
585 false
586 }
587
588 fn delete_char_before_cursor(&mut self) {
589 tracing::debug!(
590 "delete_char: cursor={}, len={}, input={:?}",
591 self.input_editor.cursor(),
592 self.input_editor.text().len(),
593 self.input_editor.text()
594 );
595
596 if self.autocomplete_manager.is_active() {
598 let should_close = self.autocomplete_manager.backspace();
599
600 if should_close
601 && self.input_editor.cursor() > 0
602 && self.input_editor.char_before_cursor() == Some('@')
603 {
604 self.input_editor.delete_char_before();
605 self.autocomplete_manager.deactivate();
606 return;
607 }
608 }
609
610 self.input_editor.delete_char_before();
612 }
613
614 fn activate_file_autocomplete(&mut self) {
616 let trigger_pos = self.input_editor.cursor() - 1; self.autocomplete_manager.activate(trigger_pos);
618
619 tracing::info!(
620 "🔍 ACTIVATED AUTOCOMPLETE: trigger_pos={}, cursor={}, text='{}'",
621 trigger_pos,
622 self.input_editor.cursor(),
623 self.input_editor.text()
624 );
625 }
626
627 fn accept_file_completion(&mut self) {
629 let selected = self.autocomplete_manager.selected_match().cloned();
632
633 if let Some(selected) = selected {
634 let trigger_pos = self.autocomplete_manager.trigger_pos().unwrap_or(0);
635 let current_cursor = self.input_editor.cursor();
636
637 tracing::info!(
638 "🎯 ACCEPT: trigger_pos={}, cursor={}, path='{}'",
639 trigger_pos,
640 current_cursor,
641 selected.path
642 );
643
644 let remove_start = trigger_pos + 1;
646 let remove_end = current_cursor;
647
648 tracing::info!(
649 "🎯 REMOVE RANGE: {}..{} = '{}'",
650 remove_start,
651 remove_end,
652 &self.input_editor.text()
653 [remove_start..remove_end.min(self.input_editor.text().len())]
654 );
655
656 if remove_end > remove_start {
659 self.input_editor
660 .replace_range(remove_start, remove_end, &selected.path);
661 } else {
662 self.input_editor.set_cursor(remove_start);
664 self.input_editor.insert_str(&selected.path);
665 }
666
667 self.input_editor.insert_char(' ');
669
670 tracing::info!(
671 "🎯 FINAL: '{}', cursor={}",
672 self.input_editor.text(),
673 self.input_editor.cursor()
674 );
675 }
676
677 self.autocomplete_manager.deactivate();
679 }
680
681 fn handle_enter(&mut self) -> Result<(), CliError> {
682 let text = self.input_editor.take_trimmed();
683
684 if text.is_empty() {
685 return Ok(());
686 }
687
688 tracing::info!("Enter pressed with text: {:?}", text);
689
690 if text.starts_with('/') {
692 use crate::tui::commands::{CommandContext, CommandResult};
693
694 let mut cmd_ctx = CommandContext::new(
696 self.tui_bridge.chat_view().clone(),
697 self.tui_bridge.session_manager(),
698 self.tui_bridge.session_id(),
699 self.tui_bridge.state_arc(),
700 self.tui_bridge.messages(),
701 self.tui_bridge.total_input_tokens_arc(),
702 self.tui_bridge.total_output_tokens_arc(),
703 None, );
705
706 match self.command_registry.parse_and_execute(&text, &mut cmd_ctx) {
708 Ok(Some(result)) => {
709 self.tui_bridge
711 .session_id_arc()
712 .lock()
713 .unwrap()
714 .clone_from(&cmd_ctx.session_id);
715
716 match result {
717 CommandResult::Exit => {
718 self.running = false;
719 return Ok(());
720 }
721 CommandResult::ClearChat => {
722 return Ok(());
724 }
725 CommandResult::NewSession | CommandResult::LoadSession(_) => {
726 *self.tui_bridge.messages().lock().unwrap() =
728 cmd_ctx.messages.lock().unwrap().clone();
729 *self.tui_bridge.total_input_tokens_arc().lock().unwrap() =
730 *cmd_ctx.total_input_tokens.lock().unwrap();
731 *self.tui_bridge.total_output_tokens_arc().lock().unwrap() =
732 *cmd_ctx.total_output_tokens.lock().unwrap();
733 return Ok(());
734 }
735 CommandResult::Continue
736 | CommandResult::Message(_)
737 | CommandResult::Share(_) => {
738 return Ok(());
739 }
740 }
741 }
742 Ok(None) => {
743 }
745 Err(e) => {
746 tracing::error!("Command error: {}", e);
747 return Err(e);
748 }
749 }
750 }
751
752 let text_lower = text.to_lowercase();
754 if text_lower == "exit" || text_lower == "quit" {
755 tracing::info!("Exit command detected, exiting");
756 self.running = false;
757 return Ok(());
758 }
759
760 if text_lower == "clear" {
761 tracing::info!("Clear command detected");
762 self.tui_bridge.chat_view().lock().unwrap().clear();
763 return Ok(());
764 }
765
766 if text_lower == "help" {
767 tracing::info!("Help command detected");
768 let help_msg = Message::system(
769 "Available commands:\n\
770 /help - Show this help message\n\
771 /clear - Clear chat history\n\
772 /exit - Exit the application\n\
773 /quit - Exit the application\n\
774 /session list - List all sessions\n\
775 /session new - Create a new session\n\
776 /session load <id> - Load a session by ID\n\
777 /share - Copy session to clipboard (markdown)\n\
778 /share md - Export session as markdown file\n\
779 /share json - Export session as JSON file\n\
780 \n\
781 Page Up/Down - Scroll chat history"
782 .to_string(),
783 );
784 self.tui_bridge
785 .chat_view()
786 .lock()
787 .unwrap()
788 .add_message(help_msg);
789 return Ok(());
790 }
791
792 self.tui_bridge.add_user_message(text.clone());
794
795 let operation_id = self.tui_bridge.next_operation_id();
797 tracing::debug!("handle_enter: new operation_id={}", operation_id);
798 self.tui_bridge.set_state(TuiState::Idle);
799
800 let cancel_token = tokio_util::sync::CancellationToken::new();
802 self.cancellation_token = Some(cancel_token.clone());
803
804 let messages = self.tui_bridge.messages();
806 let agent_bridge = self.tui_bridge.agent_bridge_arc();
807 let session_manager = self.tui_bridge.session_manager();
808 let session_id = self.tui_bridge.session_id();
809 let total_input_tokens = self.tui_bridge.total_input_tokens_arc();
810 let total_output_tokens = self.tui_bridge.total_output_tokens_arc();
811
812 tracing::debug!("Spawning LLM processing thread");
813
814 std::thread::spawn(move || {
816 let rt = tokio::runtime::Runtime::new().unwrap();
818
819 #[allow(clippy::await_holding_lock)]
821 rt.block_on(async {
822 if cancel_token.is_cancelled() {
824 tracing::debug!("Operation cancelled before acquiring locks");
825 return;
826 }
827
828 let messages_guard = {
830 let mut attempts = 0;
831 loop {
832 if cancel_token.is_cancelled() {
833 tracing::debug!("Operation cancelled while waiting for messages lock");
834 return;
835 }
836 match messages.try_lock() {
837 Ok(guard) => break guard,
838 Err(std::sync::TryLockError::WouldBlock) => {
839 attempts += 1;
840 if attempts > 50 {
841 tracing::error!("Timeout waiting for messages lock");
842 return;
843 }
844 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
845 }
846 Err(e) => {
847 tracing::error!("Failed to lock messages: {}", e);
848 return;
849 }
850 }
851 }
852 };
853
854 let mut messages_guard = messages_guard;
855
856 if cancel_token.is_cancelled() {
858 tracing::debug!("Operation cancelled before acquiring bridge lock");
859 return;
860 }
861
862 let bridge_guard = {
863 let mut attempts = 0;
864 loop {
865 if cancel_token.is_cancelled() {
866 tracing::debug!("Operation cancelled while waiting for bridge lock");
867 return;
868 }
869 match agent_bridge.try_lock() {
870 Ok(guard) => break guard,
871 Err(std::sync::TryLockError::WouldBlock) => {
872 attempts += 1;
873 if attempts > 50 {
874 tracing::error!("Timeout waiting for bridge lock");
875 return;
876 }
877 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
878 }
879 Err(e) => {
880 tracing::error!("Failed to lock agent_bridge: {}", e);
881 return;
882 }
883 }
884 }
885 };
886
887 let mut bridge = bridge_guard;
888
889 bridge.set_cancellation_token(cancel_token.clone(), operation_id);
891
892 match bridge.process_message(&text, &mut messages_guard).await {
893 Ok(_response) => {
894 let msgs = messages_guard.clone();
900 let input_tokens = *total_input_tokens.lock().unwrap();
901 let output_tokens = *total_output_tokens.lock().unwrap();
902
903 if let Err(e) = session_manager.lock().unwrap().save_session(
904 &session_id,
905 &msgs,
906 input_tokens,
907 output_tokens,
908 ) {
909 tracing::error!("✗ Failed to auto-save session {}: {}", session_id, e);
910 } else {
911 tracing::info!(
912 "✓ Session {} auto-saved ({} messages, {} in, {} out tokens)",
913 session_id,
914 msgs.len(),
915 input_tokens,
916 output_tokens
917 );
918 }
919 }
920 Err(e) => {
921 let error_msg = e.to_string();
923 if error_msg.contains("cancelled") {
924 tracing::info!("Request cancelled by user");
925 } else {
926 tracing::error!("LLM error: {}", e);
927 }
928 }
929 }
930
931 bridge.clear_cancellation_token();
933 });
934 });
935
936 Ok(())
937 }
938
939 fn cancel_current_operation(&mut self) {
941 if let Some(ref token) = self.cancellation_token {
942 token.cancel();
943 tracing::debug!("Cancellation token triggered");
944
945 self.tui_bridge.next_operation_id();
947
948 self.tui_bridge.set_state(TuiState::Idle);
950
951 self.status_message = "Operation cancelled".to_string();
953 self.status_is_error = false;
954
955 self.tui_bridge
957 .activity_feed()
958 .lock()
959 .unwrap()
960 .complete_all();
961
962 let cancel_msg = Message::system("⚠ Operation cancelled by user".to_string());
964 self.tui_bridge
965 .chat_view()
966 .lock()
967 .unwrap()
968 .add_message(cancel_msg);
969 }
970 self.cancellation_token = None;
971 self.input_handler.reset_esc_time();
973 }
974
975 fn draw(&mut self) -> Result<(), CliError> {
976 let chat_view = self.tui_bridge.chat_view().clone();
977 let cursor_pos = self.input_editor.cursor();
978 let cursor_blink_state = self.input_handler.cursor_blink_state();
979 let tui_bridge = &self.tui_bridge;
980 let file_autocomplete = self.autocomplete_manager.to_legacy_state();
981
982 self.terminal
983 .draw(|f| {
984 UiRenderer::render(
985 f,
986 f.area(),
987 &chat_view,
988 self.input_editor.text(),
989 cursor_pos,
990 &self.status_message,
991 self.status_is_error,
992 cursor_blink_state,
993 tui_bridge,
994 &file_autocomplete,
995 );
996 })
997 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
998
999 Ok(())
1000 }
1001}
1002
1003#[cfg(test)]
1004mod tests {
1005 use super::*;
1006 use crate::agent_bridge::AgentBridge;
1007 use crate::tui::bridge::TuiBridge;
1008 use tokio::sync::mpsc;
1009
1010 fn create_test_config() -> limit_llm::Config {
1012 use limit_llm::ProviderConfig;
1013 let mut providers = std::collections::HashMap::new();
1014 providers.insert(
1015 "anthropic".to_string(),
1016 ProviderConfig {
1017 api_key: Some("test-key".to_string()),
1018 model: "claude-3-5-sonnet-20241022".to_string(),
1019 base_url: None,
1020 max_tokens: 4096,
1021 timeout: 60,
1022 max_iterations: 100,
1023 thinking_enabled: false,
1024 clear_thinking: true,
1025 },
1026 );
1027 limit_llm::Config {
1028 provider: "anthropic".to_string(),
1029 providers,
1030 }
1031 }
1032
1033 #[test]
1034 fn test_tui_app_new() {
1035 let config = create_test_config();
1036 let agent_bridge = AgentBridge::new(config).unwrap();
1037 let (_tx, rx) = mpsc::unbounded_channel();
1038
1039 let tui_bridge = TuiBridge::new(agent_bridge, rx).unwrap();
1040 let app = TuiApp::new(tui_bridge);
1041 assert!(app.is_ok());
1042 }
1043}