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