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 history_path: std::path::PathBuf,
31 status_message: String,
32 status_is_error: bool,
33 mouse_selection_start: Option<(u16, u16)>,
35 clipboard: Option<ClipboardManager>,
37 autocomplete_manager: FileAutocompleteManager,
39 cancellation_token: Option<tokio_util::sync::CancellationToken>,
41 input_handler: InputHandler,
43 command_registry: crate::tui::commands::CommandRegistry,
45}
46
47impl TuiApp {
48 pub fn new(tui_bridge: TuiBridge) -> Result<Self, CliError> {
50 let backend = CrosstermBackend::new(io::stdout());
51 let terminal =
52 Terminal::new(backend).map_err(|e| CliError::IoError(io::Error::other(e)))?;
53
54 let session_id = tui_bridge.session_id();
55 tracing::info!("TUI started with session: {}", session_id);
56
57 let home_dir = dirs::home_dir()
59 .ok_or_else(|| CliError::ConfigError("Failed to get home directory".to_string()))?;
60 let limit_dir = home_dir.join(".limit");
61 let history_path = limit_dir.join("input_history.bin");
62
63 let input_editor = InputEditor::with_history(&history_path)
65 .map_err(|e| CliError::ConfigError(format!("Failed to load input history: {}", e)))?;
66
67 let clipboard = match ClipboardManager::new() {
68 Ok(cb) => {
69 tracing::debug!("✓ Clipboard initialized successfully");
70 tracing::info!("Clipboard initialized successfully");
71 Some(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 tracing::debug!(
157 "Event::Key - code={:?} mod={:?} kind={:?}",
158 key.code,
159 key.modifiers,
160 key.kind
161 );
162
163 if key.kind == KeyEventKind::Press {
164 self.handle_key_event(key)?;
165 }
166 }
167 Event::Mouse(mouse) => {
168 match mouse.kind {
169 MouseEventKind::Down(MouseButton::Left) => {
170 tracing::debug!("MouseDown at ({}, {})", mouse.column, mouse.row);
171 self.mouse_selection_start = Some((mouse.column, mouse.row));
172 let chat = self.tui_bridge.chat_view().lock().unwrap();
174 tracing::debug!(
175 " render_positions count: {}",
176 chat.render_position_count()
177 );
178 if let Some((msg_idx, char_offset)) =
179 chat.screen_to_text_pos(mouse.column, mouse.row)
180 {
181 tracing::debug!(
182 " -> Starting selection at msg={}, offset={}",
183 msg_idx,
184 char_offset
185 );
186 drop(chat);
187 self.tui_bridge
188 .chat_view()
189 .lock()
190 .unwrap()
191 .start_selection(msg_idx, char_offset);
192 } else {
193 tracing::debug!(" -> No match, clearing selection");
194 drop(chat);
195 self.tui_bridge
196 .chat_view()
197 .lock()
198 .unwrap()
199 .clear_selection();
200 }
201 }
202 MouseEventKind::Drag(MouseButton::Left) => {
203 if self.mouse_selection_start.is_some() {
204 let chat = self.tui_bridge.chat_view().lock().unwrap();
206 if let Some((msg_idx, char_offset)) =
207 chat.screen_to_text_pos(mouse.column, mouse.row)
208 {
209 drop(chat);
210 self.tui_bridge
211 .chat_view()
212 .lock()
213 .unwrap()
214 .extend_selection(msg_idx, char_offset);
215 }
216 }
217 }
218 MouseEventKind::Up(MouseButton::Left) => {
219 self.mouse_selection_start = None;
220 }
221 MouseEventKind::ScrollUp => {
222 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
223 chat.scroll_up();
224 }
225 MouseEventKind::ScrollDown => {
226 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
227 chat.scroll_down();
228 }
229 _ => {}
230 }
231 }
232 Event::Paste(pasted) => {
233 if !self.tui_bridge.is_busy() {
234 self.insert_paste(&pasted);
235 }
236 }
237 _ => {}
238 }
239 } else {
240 self.tick_cursor_blink();
242 }
243
244 self.draw()?;
246 }
247
248 if let Err(e) = self.tui_bridge.save_session() {
250 tracing::error!("Failed to save session: {}", e);
251 }
252
253 if let Err(e) = self.input_editor.save_history(&self.history_path) {
255 tracing::error!("Failed to save input history: {}", e);
256 }
257
258 Ok(())
259 }
260
261 fn update_status(&mut self) {
262 let session_id = self.tui_bridge.session_id();
263 let has_activity = self
264 .tui_bridge
265 .activity_feed()
266 .lock()
267 .unwrap()
268 .has_in_progress();
269
270 match self.tui_bridge.state() {
271 TuiState::Idle => {
272 if has_activity {
273 let spinner = self.tui_bridge.spinner().lock().unwrap();
275 self.status_message = format!("{} Processing...", spinner.current_frame());
276 } else {
277 self.status_message = format!(
278 "Ready | Session: {}",
279 session_id.chars().take(8).collect::<String>()
280 );
281 }
282 self.status_is_error = false;
283 }
284 TuiState::Thinking => {
285 let spinner = self.tui_bridge.spinner().lock().unwrap();
286 self.status_message = format!("{} Thinking...", spinner.current_frame());
287 self.status_is_error = false;
288 }
289 }
290 }
291
292 fn insert_paste(&mut self, text: &str) {
294 let truncated = self.input_editor.insert_paste(text);
295 if truncated {
296 self.status_message = "Paste truncated (too large)".to_string();
297 self.status_is_error = true;
298 }
299 }
300
301 fn is_copy_paste_modifier(&self, key: &KeyEvent, char: char) -> bool {
305 #[cfg(target_os = "macos")]
306 {
307 let has_super = key.modifiers.contains(KeyModifiers::SUPER);
309 let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
310 let result = key.code == KeyCode::Char(char) && (has_super || has_ctrl);
311 tracing::debug!("is_copy_paste_modifier('{}') macOS: code={:?}, mod={:?}, super={}, ctrl={}, result={}",
312 char, key.code, key.modifiers, has_super, has_ctrl, result);
313 result
314 }
315 #[cfg(not(target_os = "macos"))]
316 {
317 let result =
318 key.code == KeyCode::Char(char) && key.modifiers.contains(KeyModifiers::CONTROL);
319 tracing::debug!(
320 "is_copy_paste_modifier('{}') non-macOS: code={:?}, mod={:?}, ctrl={:?}, result={}",
321 char,
322 key.code,
323 key.modifiers,
324 KeyModifiers::CONTROL,
325 result
326 );
327 result
328 }
329 }
330
331 fn tick_cursor_blink(&mut self) {
332 self.input_handler.tick_cursor_blink();
334 }
335
336 fn handle_key_event(&mut self, key: KeyEvent) -> Result<(), CliError> {
337 tracing::debug!(
339 "handle_key_event: code={:?} mod={:?} kind={:?}",
340 key.code,
341 key.modifiers,
342 key.kind
343 );
344
345 if matches!(key.code, KeyCode::Char('c') | KeyCode::Char('v')) {
347 tracing::debug!(
348 ">>> SPECIAL: '{}' key detected with modifiers: {:?} (SUPER={:?}, CONTROL={:?})",
349 if matches!(key.code, KeyCode::Char('c')) {
350 'c'
351 } else {
352 'v'
353 },
354 key.modifiers,
355 key.modifiers.contains(KeyModifiers::SUPER),
356 key.modifiers.contains(KeyModifiers::CONTROL)
357 );
358 }
359
360 if self.is_copy_paste_modifier(&key, 'c') {
362 tracing::debug!("✓ Copy shortcut CONFIRMED - processing...");
363 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
364 let has_selection = chat.has_selection();
365 tracing::debug!("has_selection={}", has_selection);
366
367 if has_selection {
368 if let Some(selected) = chat.get_selected_text() {
369 tracing::debug!("Selected text length={}", selected.len());
370 if !selected.is_empty() {
371 if let Some(ref clipboard) = self.clipboard {
372 tracing::debug!("Attempting to copy to clipboard...");
373 match clipboard.set_text(&selected) {
374 Ok(()) => {
375 tracing::debug!("✓ Clipboard copy successful");
376 self.status_message = "Copied to clipboard".to_string();
377 self.status_is_error = false;
378 }
379 Err(e) => {
380 tracing::debug!("✗ Clipboard copy failed: {}", e);
381 self.status_message = format!("Clipboard error: {}", e);
382 self.status_is_error = true;
383 }
384 }
385 } else {
386 tracing::debug!("✗ Clipboard not available (None)");
387 self.status_message = "Clipboard not available".to_string();
388 self.status_is_error = true;
389 }
390 } else {
391 tracing::debug!("Selected text is empty");
392 }
393 chat.clear_selection();
394 } else {
395 tracing::debug!("get_selected_text() returned None");
396 }
397 return Ok(());
398 }
399
400 tracing::debug!("Ctrl/Cmd+C with no selection - ignoring");
402 return Ok(());
403 }
404
405 if self.is_copy_paste_modifier(&key, 'v') && !self.tui_bridge.is_busy() {
407 tracing::debug!("✓ Paste shortcut CONFIRMED - processing...");
408 if let Some(ref clipboard) = self.clipboard {
409 tracing::debug!("Attempting to read from clipboard...");
410 match clipboard.get_text() {
411 Ok(text) if !text.is_empty() => {
412 tracing::debug!("Read {} chars from clipboard", text.len());
413 self.insert_paste(&text);
414 }
415 Ok(_) => {
416 tracing::debug!("Clipboard is empty");
417 } Err(e) => {
419 tracing::debug!("✗ Failed to read clipboard: {}", e);
420 self.status_message = format!("Could not read clipboard: {}", e);
421 self.status_is_error = true;
422 }
423 }
424 } else {
425 tracing::debug!("✗ Clipboard not available (None)");
426 self.status_message = "Clipboard not available".to_string();
427 self.status_is_error = true;
428 }
429 return Ok(());
430 }
431
432 let autocomplete_active = self.autocomplete_manager.is_active();
434 tracing::debug!(
435 "Key handling: autocomplete_active={}, is_busy={}, history_len={}",
436 autocomplete_active,
437 self.tui_bridge.is_busy(),
438 self.input_editor.history().len()
439 );
440
441 if autocomplete_active {
442 match key.code {
443 KeyCode::Up => {
444 self.autocomplete_manager.navigate_up();
445 return Ok(());
446 }
447 KeyCode::Down => {
448 self.autocomplete_manager.navigate_down();
449 return Ok(());
450 }
451 KeyCode::Enter | KeyCode::Tab => {
452 self.accept_file_completion();
453 return Ok(());
454 }
455 KeyCode::Esc => {
456 self.autocomplete_manager.deactivate();
457 return Ok(());
458 }
459 _ => {}
460 }
461 }
462
463 if key.code == KeyCode::Esc {
465 if self.autocomplete_manager.is_active() {
467 self.autocomplete_manager.deactivate();
468 } else if self.tui_bridge.is_busy() {
469 let now = Instant::now();
471 let last_esc_time = self.input_handler.last_esc_time();
472 let should_cancel = if let Some(last_esc) = last_esc_time {
473 now.duration_since(last_esc) < Duration::from_millis(1000)
474 } else {
475 false
476 };
477
478 if should_cancel {
479 self.cancel_current_operation();
480 } else {
481 self.status_message = "Press ESC again to cancel".to_string();
483 self.status_is_error = false;
484 self.input_handler.set_last_esc_time(now);
485 }
486 } else {
487 tracing::debug!("Esc pressed, exiting");
488 self.running = false;
489 }
490 return Ok(());
491 }
492
493 let term_height = self.terminal.size().map(|s| s.height).unwrap_or(24);
496 let viewport_height = term_height
497 .saturating_sub(1) .saturating_sub(7); match key.code {
500 KeyCode::PageUp => {
501 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
502 chat.scroll_page_up(viewport_height);
503 return Ok(());
504 }
505 KeyCode::PageDown => {
506 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
507 chat.scroll_page_down(viewport_height);
508 return Ok(());
509 }
510 KeyCode::Up => {
511 tracing::debug!(
513 "Up arrow: navigating history up, history_len={}",
514 self.input_editor.history().len()
515 );
516 let navigated = self.input_editor.navigate_history_up();
517 tracing::debug!(
518 "Up arrow: navigated={}, text='{}'",
519 navigated,
520 self.input_editor.text()
521 );
522 return Ok(());
523 }
524 KeyCode::Down => {
525 tracing::debug!(
527 "Down arrow: navigating history down, is_navigating={}",
528 self.input_editor.is_navigating_history()
529 );
530 let navigated = self.input_editor.navigate_history_down();
531 tracing::debug!(
532 "Down arrow: navigated={}, text='{}'",
533 navigated,
534 self.input_editor.text()
535 );
536 return Ok(());
537 }
538 _ => {}
539 }
540
541 if self.tui_bridge.is_busy() {
543 tracing::debug!("Agent busy, ignoring");
544 return Ok(());
545 }
546
547 if self.handle_backspace(&key) {
549 tracing::debug!("Backspace handled, input: {:?}", self.input_editor.text());
550 return Ok(());
551 }
552
553 match key.code {
554 KeyCode::Delete => {
555 if self.input_editor.delete_char_at() {
556 tracing::debug!("Delete: input now: {:?}", self.input_editor.text());
557 }
558 }
559 KeyCode::Left => {
560 tracing::debug!(
561 "KeyCode::Left: has_pasted_content={}",
562 self.input_editor.has_pasted_content()
563 );
564 self.input_editor.move_left();
565 }
566 KeyCode::Right => {
567 tracing::debug!(
568 "KeyCode::Right: has_pasted_content={}",
569 self.input_editor.has_pasted_content()
570 );
571 self.input_editor.move_right();
572 }
573 KeyCode::Home => {
574 self.input_editor.move_to_start();
575 }
576 KeyCode::End => {
577 self.input_editor.move_to_end();
578 }
579 KeyCode::Enter => {
580 self.handle_enter()?;
582 }
583 KeyCode::Char(c)
585 if key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT =>
586 {
587 if c == '@' {
589 self.input_editor.insert_char('@');
591
592 self.activate_file_autocomplete();
594 } else if self.autocomplete_manager.is_active() {
595 self.autocomplete_manager.append_char(c);
597 self.input_editor.insert_char(c);
598 } else {
599 self.input_editor.insert_char(c);
601 }
602 }
603 _ => {
604 }
606 }
607
608 Ok(())
609 }
610
611 fn handle_backspace(&mut self, key: &KeyEvent) -> bool {
613 if key.code == KeyCode::Backspace {
615 tracing::debug!("Backspace detected via KeyCode::Backspace");
616 self.delete_char_before_cursor();
617 return true;
618 }
619
620 if key.code == KeyCode::Char('h') && key.modifiers == KeyModifiers::CONTROL {
622 tracing::debug!("Backspace detected via Ctrl+H");
623 self.delete_char_before_cursor();
624 return true;
625 }
626
627 if let KeyCode::Char(c) = key.code {
629 if c == '\x7f' || c == '\x08' {
630 tracing::debug!("Backspace detected via char code: {}", c as u8);
631 self.delete_char_before_cursor();
632 return true;
633 }
634 }
635
636 false
637 }
638
639 fn delete_char_before_cursor(&mut self) {
640 tracing::debug!(
641 "delete_char: cursor={}, len={}, input={:?}",
642 self.input_editor.cursor(),
643 self.input_editor.text().len(),
644 self.input_editor.text()
645 );
646
647 if self.autocomplete_manager.is_active() {
649 let should_close = self.autocomplete_manager.backspace();
650
651 if should_close
652 && self.input_editor.cursor() > 0
653 && self.input_editor.char_before_cursor() == Some('@')
654 {
655 self.input_editor.delete_char_before();
656 self.autocomplete_manager.deactivate();
657 return;
658 }
659 }
660
661 self.input_editor.delete_char_before();
663 }
664
665 fn activate_file_autocomplete(&mut self) {
667 let trigger_pos = self.input_editor.cursor() - 1; self.autocomplete_manager.activate(trigger_pos);
669
670 tracing::info!(
671 "🔍 ACTIVATED AUTOCOMPLETE: trigger_pos={}, cursor={}, text='{}'",
672 trigger_pos,
673 self.input_editor.cursor(),
674 self.input_editor.text()
675 );
676 }
677
678 fn accept_file_completion(&mut self) {
680 let selected = self.autocomplete_manager.selected_match().cloned();
683
684 if let Some(selected) = selected {
685 let trigger_pos = self.autocomplete_manager.trigger_pos().unwrap_or(0);
686 let current_cursor = self.input_editor.cursor();
687
688 tracing::info!(
689 "🎯 ACCEPT: trigger_pos={}, cursor={}, path='{}'",
690 trigger_pos,
691 current_cursor,
692 selected.path
693 );
694
695 let remove_start = trigger_pos + 1;
697 let remove_end = current_cursor;
698
699 tracing::info!(
700 "🎯 REMOVE RANGE: {}..{} = '{}'",
701 remove_start,
702 remove_end,
703 &self.input_editor.text()
704 [remove_start..remove_end.min(self.input_editor.text().len())]
705 );
706
707 if remove_end > remove_start {
710 self.input_editor
711 .replace_range(remove_start, remove_end, &selected.path);
712 } else {
713 self.input_editor.set_cursor(remove_start);
715 self.input_editor.insert_str(&selected.path);
716 }
717
718 self.input_editor.insert_char(' ');
720
721 tracing::info!(
722 "🎯 FINAL: '{}', cursor={}",
723 self.input_editor.text(),
724 self.input_editor.cursor()
725 );
726 }
727
728 self.autocomplete_manager.deactivate();
730 }
731
732 fn handle_enter(&mut self) -> Result<(), CliError> {
733 let text = self.input_editor.take_and_add_to_history();
734
735 if text.is_empty() {
736 return Ok(());
737 }
738
739 tracing::info!("Enter pressed with text: {:?}", text);
740
741 if text.starts_with('/') {
743 use crate::tui::commands::{CommandContext, CommandResult};
744
745 let mut cmd_ctx = CommandContext::new(
747 self.tui_bridge.chat_view().clone(),
748 self.tui_bridge.session_manager(),
749 self.tui_bridge.session_id(),
750 self.tui_bridge.state_arc(),
751 self.tui_bridge.messages(),
752 self.tui_bridge.total_input_tokens_arc(),
753 self.tui_bridge.total_output_tokens_arc(),
754 None, );
756
757 match self.command_registry.parse_and_execute(&text, &mut cmd_ctx) {
759 Ok(Some(result)) => {
760 self.tui_bridge
762 .session_id_arc()
763 .lock()
764 .unwrap()
765 .clone_from(&cmd_ctx.session_id);
766
767 match result {
768 CommandResult::Exit => {
769 self.running = false;
770 return Ok(());
771 }
772 CommandResult::ClearChat => {
773 return Ok(());
775 }
776 CommandResult::NewSession | CommandResult::LoadSession(_) => {
777 *self.tui_bridge.messages().lock().unwrap() =
779 cmd_ctx.messages.lock().unwrap().clone();
780 *self.tui_bridge.total_input_tokens_arc().lock().unwrap() =
781 *cmd_ctx.total_input_tokens.lock().unwrap();
782 *self.tui_bridge.total_output_tokens_arc().lock().unwrap() =
783 *cmd_ctx.total_output_tokens.lock().unwrap();
784 return Ok(());
785 }
786 CommandResult::Continue
787 | CommandResult::Message(_)
788 | CommandResult::Share(_) => {
789 return Ok(());
790 }
791 }
792 }
793 Ok(None) => {
794 }
796 Err(e) => {
797 tracing::error!("Command error: {}", e);
798 self.tui_bridge
800 .chat_view()
801 .lock()
802 .unwrap()
803 .add_message(Message::system(format!("Error: {}", e)));
804 return Ok(());
805 }
806 }
807 }
808
809 let text_lower = text.to_lowercase();
811 if text_lower == "exit" || text_lower == "quit" {
812 tracing::info!("Exit command detected, exiting");
813 self.running = false;
814 return Ok(());
815 }
816
817 if text_lower == "clear" {
818 tracing::info!("Clear command detected");
819 self.tui_bridge.chat_view().lock().unwrap().clear();
820 return Ok(());
821 }
822
823 if text_lower == "help" {
824 tracing::info!("Help command detected");
825 let help_msg = Message::system(
826 "Available commands:\n\
827 /help - Show this help message\n\
828 /clear - Clear chat history\n\
829 /exit - Exit the application\n\
830 /quit - Exit the application\n\
831 /session list - List all sessions\n\
832 /session new - Create a new session\n\
833 /session load <id> - Load a session by ID\n\
834 /share - Copy session to clipboard (markdown)\n\
835 /share md - Export session as markdown file\n\
836 /share json - Export session as JSON file\n\
837 \n\
838 Page Up/Down - Scroll chat history\n\
839 Up/Down (empty input) - Navigate input history"
840 .to_string(),
841 );
842 self.tui_bridge
843 .chat_view()
844 .lock()
845 .unwrap()
846 .add_message(help_msg);
847 return Ok(());
848 }
849
850 self.tui_bridge.add_user_message(text.clone());
852
853 let operation_id = self.tui_bridge.next_operation_id();
855 tracing::debug!("handle_enter: new operation_id={}", operation_id);
856 self.tui_bridge.set_state(TuiState::Idle);
857
858 let cancel_token = tokio_util::sync::CancellationToken::new();
860 self.cancellation_token = Some(cancel_token.clone());
861
862 let messages = self.tui_bridge.messages();
864 let agent_bridge = self.tui_bridge.agent_bridge_arc();
865 let session_manager = self.tui_bridge.session_manager();
866 let session_id = self.tui_bridge.session_id();
867 let total_input_tokens = self.tui_bridge.total_input_tokens_arc();
868 let total_output_tokens = self.tui_bridge.total_output_tokens_arc();
869
870 tracing::debug!("Spawning LLM processing thread");
871
872 std::thread::spawn(move || {
874 let rt = tokio::runtime::Runtime::new().unwrap();
876
877 #[allow(clippy::await_holding_lock)]
879 rt.block_on(async {
880 if cancel_token.is_cancelled() {
882 tracing::debug!("Operation cancelled before acquiring locks");
883 return;
884 }
885
886 let messages_guard = {
888 let mut attempts = 0;
889 loop {
890 if cancel_token.is_cancelled() {
891 tracing::debug!("Operation cancelled while waiting for messages lock");
892 return;
893 }
894 match messages.try_lock() {
895 Ok(guard) => break guard,
896 Err(std::sync::TryLockError::WouldBlock) => {
897 attempts += 1;
898 if attempts > 50 {
899 tracing::error!("Timeout waiting for messages lock");
900 return;
901 }
902 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
903 }
904 Err(e) => {
905 tracing::error!("Failed to lock messages: {}", e);
906 return;
907 }
908 }
909 }
910 };
911
912 let mut messages_guard = messages_guard;
913
914 if cancel_token.is_cancelled() {
916 tracing::debug!("Operation cancelled before acquiring bridge lock");
917 return;
918 }
919
920 let bridge_guard = {
921 let mut attempts = 0;
922 loop {
923 if cancel_token.is_cancelled() {
924 tracing::debug!("Operation cancelled while waiting for bridge lock");
925 return;
926 }
927 match agent_bridge.try_lock() {
928 Ok(guard) => break guard,
929 Err(std::sync::TryLockError::WouldBlock) => {
930 attempts += 1;
931 if attempts > 50 {
932 tracing::error!("Timeout waiting for bridge lock");
933 return;
934 }
935 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
936 }
937 Err(e) => {
938 tracing::error!("Failed to lock agent_bridge: {}", e);
939 return;
940 }
941 }
942 }
943 };
944
945 let mut bridge = bridge_guard;
946
947 bridge.set_cancellation_token(cancel_token.clone(), operation_id);
949
950 match bridge.process_message(&text, &mut messages_guard).await {
951 Ok(_response) => {
952 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 let error_msg = e.to_string();
981 if error_msg.contains("cancelled") {
982 tracing::info!("Request cancelled by user");
983 } else {
984 tracing::error!("LLM error: {}", e);
985 }
986 }
987 }
988
989 bridge.clear_cancellation_token();
991 });
992 });
993
994 Ok(())
995 }
996
997 fn cancel_current_operation(&mut self) {
999 if let Some(ref token) = self.cancellation_token {
1000 token.cancel();
1001 tracing::debug!("Cancellation token triggered");
1002
1003 self.tui_bridge.next_operation_id();
1005
1006 self.tui_bridge.set_state(TuiState::Idle);
1008
1009 self.status_message = "Operation cancelled".to_string();
1011 self.status_is_error = false;
1012
1013 self.tui_bridge
1015 .activity_feed()
1016 .lock()
1017 .unwrap()
1018 .complete_all();
1019
1020 let cancel_msg = Message::system("⚠ Operation cancelled by user".to_string());
1022 self.tui_bridge
1023 .chat_view()
1024 .lock()
1025 .unwrap()
1026 .add_message(cancel_msg);
1027 }
1028 self.cancellation_token = None;
1029 self.input_handler.reset_esc_time();
1031 }
1032
1033 fn draw(&mut self) -> Result<(), CliError> {
1034 let chat_view = self.tui_bridge.chat_view().clone();
1035 let display_text = self.input_editor.display_text_combined();
1036 let cursor_pos = self.input_editor.cursor();
1037 let cursor_blink_state = self.input_handler.cursor_blink_state();
1038 let tui_bridge = &self.tui_bridge;
1039 let file_autocomplete = self.autocomplete_manager.to_legacy_state();
1040
1041 self.terminal
1042 .draw(|f| {
1043 UiRenderer::render(
1044 f,
1045 f.area(),
1046 &chat_view,
1047 &display_text,
1048 cursor_pos,
1049 &self.status_message,
1050 self.status_is_error,
1051 cursor_blink_state,
1052 tui_bridge,
1053 &file_autocomplete,
1054 );
1055 })
1056 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
1057
1058 Ok(())
1059 }
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064 use super::*;
1065 use crate::agent_bridge::AgentBridge;
1066 use crate::tui::bridge::TuiBridge;
1067 use tokio::sync::mpsc;
1068
1069 fn create_test_config() -> limit_llm::Config {
1071 use limit_llm::{BrowserConfigSection, ProviderConfig};
1072 let mut providers = std::collections::HashMap::new();
1073 providers.insert(
1074 "anthropic".to_string(),
1075 ProviderConfig {
1076 api_key: Some("test-key".to_string()),
1077 model: "claude-3-5-sonnet-20241022".to_string(),
1078 base_url: None,
1079 max_tokens: 4096,
1080 timeout: 60,
1081 max_iterations: 100,
1082 thinking_enabled: false,
1083 clear_thinking: true,
1084 },
1085 );
1086 limit_llm::Config {
1087 provider: "anthropic".to_string(),
1088 providers,
1089 browser: BrowserConfigSection::default(),
1090 }
1091 }
1092
1093 #[test]
1094 fn test_tui_app_new() {
1095 let config = create_test_config();
1096 let agent_bridge = AgentBridge::new(config).unwrap();
1097 let (_tx, rx) = mpsc::unbounded_channel();
1098
1099 let tui_bridge = TuiBridge::new(agent_bridge, rx).unwrap();
1100 let app = TuiApp::new(tui_bridge);
1101 assert!(app.is_ok());
1102 }
1103}