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 pending_images: Vec<std::path::PathBuf>,
48 input_queue: crate::tui::input_queue::InputQueue,
50}
51
52impl TuiApp {
53 pub fn new(tui_bridge: TuiBridge) -> Result<Self, CliError> {
55 let backend = CrosstermBackend::new(io::stdout());
56 let terminal =
57 Terminal::new(backend).map_err(|e| CliError::IoError(io::Error::other(e)))?;
58
59 let session_id = tui_bridge.session_id();
60 tracing::info!("TUI started with session: {}", session_id);
61
62 let home_dir = dirs::home_dir()
64 .ok_or_else(|| CliError::ConfigError("Failed to get home directory".to_string()))?;
65 let limit_dir = home_dir.join(".limit");
66 let history_path = limit_dir.join("input_history.bin");
67
68 let input_editor = InputEditor::with_history(&history_path)
70 .map_err(|e| CliError::ConfigError(format!("Failed to load input history: {}", e)))?;
71
72 let clipboard = match ClipboardManager::new() {
73 Ok(cb) => {
74 tracing::info!("Clipboard initialized successfully");
75 Some(Arc::new(Mutex::new(cb)))
76 }
77 Err(e) => {
78 tracing::debug!("✗ Clipboard initialization failed: {}", e);
79 tracing::warn!("Clipboard unavailable: {}", e);
80 None
81 }
82 };
83
84 let working_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
86 let autocomplete_manager = FileAutocompleteManager::new(working_dir);
87
88 let command_registry = crate::tui::commands::create_default_registry();
90
91 Ok(Self {
92 tui_bridge,
93 terminal,
94 running: true,
95 input_editor,
96 history_path,
97 status_message: "Ready - Type a message and press Enter".to_string(),
98 status_is_error: false,
99 mouse_selection_start: None,
100 clipboard,
101 autocomplete_manager,
102 cancellation_token: None,
103 input_handler: InputHandler::new(),
104 command_registry,
105 pending_images: Vec::new(),
106 input_queue: crate::tui::input_queue::InputQueue::new(),
107 })
108 }
109
110 pub fn run(&mut self) -> Result<(), CliError> {
112 execute!(std::io::stdout(), EnterAlternateScreen)
114 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
115
116 execute!(std::io::stdout(), EnableMouseCapture)
118 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
119
120 execute!(std::io::stdout(), EnableBracketedPaste)
122 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
123
124 crossterm::terminal::enable_raw_mode()
125 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
126
127 struct AlternateScreenGuard;
129 impl Drop for AlternateScreenGuard {
130 fn drop(&mut self) {
131 let _ = crossterm::terminal::disable_raw_mode();
132 let _ = execute!(std::io::stdout(), DisableBracketedPaste);
133 let _ = execute!(std::io::stdout(), DisableMouseCapture);
134 let _ = execute!(std::io::stdout(), LeaveAlternateScreen);
135 }
136 }
137 let _guard = AlternateScreenGuard;
138
139 self.run_inner()
140 }
141
142 fn run_inner(&mut self) -> Result<(), CliError> {
143 while self.running {
144 self.tui_bridge.process_events()?;
146
147 if matches!(self.tui_bridge.state(), TuiState::Thinking) {
149 self.tui_bridge.tick_spinner();
150 }
151
152 self.update_status();
154
155 if event::poll(Duration::from_millis(100))
157 .map_err(|e| CliError::IoError(io::Error::other(e)))?
158 {
159 match event::read().map_err(|e| CliError::IoError(io::Error::other(e)))? {
160 Event::Key(key) => {
161 if key.kind == KeyEventKind::Press {
162 self.handle_key_event(key)?;
163 }
164 }
165 Event::Mouse(mouse) => {
166 match mouse.kind {
167 MouseEventKind::Down(MouseButton::Left) => {
168 self.mouse_selection_start = Some((mouse.column, mouse.row));
169 let chat = self.tui_bridge.chat_view().lock().unwrap();
171 if let Some((msg_idx, char_offset)) =
172 chat.screen_to_text_pos(mouse.column, mouse.row)
173 {
174 drop(chat);
175 self.tui_bridge
176 .chat_view()
177 .lock()
178 .unwrap()
179 .start_selection(msg_idx, char_offset);
180 } else {
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 if let Err(e) = self.input_editor.save_history(&self.history_path) {
242 tracing::error!("Failed to save input history: {}", e);
243 }
244
245 Ok(())
246 }
247
248 fn update_status(&mut self) {
249 if self.status_message.starts_with("Image attached") {
251 return;
252 }
253
254 let session_id = self.tui_bridge.session_id();
255 let has_activity = self
256 .tui_bridge
257 .activity_feed()
258 .lock()
259 .unwrap()
260 .has_in_progress();
261
262 match self.tui_bridge.state() {
263 TuiState::Idle => {
264 self.maybe_send_next_queued_input();
266
267 if has_activity {
268 let spinner = self.tui_bridge.spinner().lock().unwrap();
270 self.status_message = format!("{} Processing...", spinner.current_frame());
271 } else if self.input_queue.has_queued_messages()
272 || self.input_queue.has_pending_steers()
273 {
274 let queued = self.input_queue.queued_count();
276 let steers = self.input_queue.steer_count();
277 let mut parts = vec![];
278 if queued > 0 {
279 parts.push(format!("{} queued", queued));
280 }
281 if steers > 0 {
282 parts.push(format!("{} pending", steers));
283 }
284 self.status_message =
285 format!("Ready | {} message(s) in queue", parts.join(", "));
286 } else {
287 self.status_message = format!(
288 "Ready | Session: {}",
289 session_id.chars().take(8).collect::<String>()
290 );
291 }
292 self.status_is_error = false;
293 }
294 TuiState::Thinking => {
295 let spinner = self.tui_bridge.spinner().lock().unwrap();
296 if self.input_queue.has_queued_messages() || self.input_queue.has_pending_steers() {
297 let queued = self.input_queue.queued_count();
298 let steers = self.input_queue.steer_count();
299 let mut parts = vec![];
300 if queued > 0 {
301 parts.push(format!("{} queued", queued));
302 }
303 if steers > 0 {
304 parts.push(format!("{} pending", steers));
305 }
306 self.status_message = format!(
307 "{} Thinking... | {} message(s) waiting",
308 spinner.current_frame(),
309 parts.join(", ")
310 );
311 } else {
312 self.status_message = format!("{} Thinking...", spinner.current_frame());
313 }
314 self.status_is_error = false;
315 }
316 }
317 }
318
319 fn insert_paste(&mut self, text: &str) {
321 let truncated = self.input_editor.insert_paste(text);
322 if truncated {
323 self.status_message = "Paste truncated (too large)".to_string();
324 self.status_is_error = true;
325 }
326 }
327
328 fn is_copy_paste_modifier(&self, key: &KeyEvent, char: char) -> bool {
332 #[cfg(target_os = "macos")]
333 {
334 let has_super = key.modifiers.contains(KeyModifiers::SUPER);
336 let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
337 let result = key.code == KeyCode::Char(char) && (has_super || has_ctrl);
338 tracing::trace!("is_copy_paste_modifier('{}') macOS: code={:?}, mod={:?}, super={}, ctrl={}, result={}",
339 char, key.code, key.modifiers, has_super, has_ctrl, result);
340 result
341 }
342 #[cfg(not(target_os = "macos"))]
343 {
344 let result =
345 key.code == KeyCode::Char(char) && key.modifiers.contains(KeyModifiers::CONTROL);
346 tracing::trace!(
347 "is_copy_paste_modifier('{}') non-macOS: code={:?}, mod={:?}, ctrl={:?}, result={}",
348 char,
349 key.code,
350 key.modifiers,
351 KeyModifiers::CONTROL,
352 result
353 );
354 result
355 }
356 }
357
358 fn tick_cursor_blink(&mut self) {
359 self.input_handler.tick_cursor_blink();
361 }
362
363 fn handle_key_event(&mut self, key: KeyEvent) -> Result<(), CliError> {
364 if self.is_copy_paste_modifier(&key, 'c') {
366 tracing::trace!("✓ Copy shortcut CONFIRMED - processing...");
367 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
368 let has_selection = chat.has_selection();
369 tracing::trace!("has_selection={}", has_selection);
370
371 if has_selection {
372 if let Some(selected) = chat.get_selected_text() {
373 tracing::trace!("Selected text length={}", selected.len());
374 if !selected.is_empty() {
375 if let Some(ref clipboard) = self.clipboard {
376 tracing::trace!("Attempting to copy to clipboard...");
377 match clipboard.lock().unwrap().set_text(&selected) {
378 Ok(()) => {
379 tracing::trace!("✓ Clipboard copy successful");
380 self.status_message = "Copied to clipboard".to_string();
381 self.status_is_error = false;
382 }
383 Err(e) => {
384 tracing::debug!("✗ Clipboard copy failed: {}", e);
385 self.status_message = format!("Clipboard error: {}", e);
386 self.status_is_error = true;
387 }
388 }
389 } else {
390 tracing::debug!("✗ Clipboard not available (None)");
391 self.status_message = "Clipboard not available".to_string();
392 self.status_is_error = true;
393 }
394 } else {
395 tracing::trace!("Selected text is empty");
396 }
397 chat.clear_selection();
398 } else {
399 tracing::trace!("get_selected_text() returned None");
400 }
401 return Ok(());
402 }
403
404 tracing::trace!("Ctrl/Cmd+C with no selection - ignoring");
406 return Ok(());
407 }
408
409 let is_paste_shortcut = {
412 #[cfg(target_os = "macos")]
413 {
414 let has_mod = key.modifiers.contains(KeyModifiers::SUPER)
415 || key.modifiers.contains(KeyModifiers::CONTROL);
416 let is_v = key.code == KeyCode::Char('v');
417 is_v && has_mod
418 }
419 #[cfg(not(target_os = "macos"))]
420 {
421 self.is_copy_paste_modifier(&key, 'v')
422 }
423 };
424
425 if key.code == KeyCode::Char('v')
427 && key.modifiers.contains(KeyModifiers::ALT)
428 && !self.tui_bridge.is_busy()
429 {
430 tracing::trace!("Attempting image paste (Alt+V)...");
431
432 let model = self
434 .tui_bridge
435 .agent_bridge_arc()
436 .lock()
437 .unwrap()
438 .model()
439 .to_lowercase();
440 let provider = self
441 .tui_bridge
442 .agent_bridge_arc()
443 .lock()
444 .unwrap()
445 .provider_name()
446 .to_lowercase();
447
448 let supports_vision = {
449 if provider == "openai" || provider == "openai-compatible" {
451 model.contains("gpt-4o")
452 || model.contains("gpt-4-turbo")
453 || model.contains("gpt-4-vision")
454 } else if provider == "anthropic" || provider == "claude" {
456 model.contains("claude-3")
457 } else if provider == "google" || provider == "gemini" {
459 model.contains("gemini")
460 } else {
462 false
463 }
464 };
465
466 if !supports_vision {
467 self.status_message = "Current provider/model does not support images. Use a vision-capable model like gpt-4o or claude-3.".to_string();
468 self.status_is_error = true;
469 return Ok(());
470 }
471
472 match crate::clipboard_paste::paste_image_to_temp_png() {
473 Ok((_path, info)) => {
474 tracing::debug!(
475 "pasted image size={}x{} format={}",
476 info.width,
477 info.height,
478 info.encoded_format.label()
479 );
480 self.pending_images.push(_path);
481 self.status_message = format!(
482 "Image attached ({}x{}) - {} image(s) pending. Press Enter to send.",
483 info.width,
484 info.height,
485 self.pending_images.len()
486 );
487 self.status_is_error = false;
488 }
489 Err(err) => {
490 tracing::warn!("failed to paste image: {err}");
491 self.status_message = format!("Failed to paste image: {err}");
492 self.status_is_error = true;
493 }
494 }
495 return Ok(());
496 }
497
498 if is_paste_shortcut && !self.tui_bridge.is_busy() {
500 let clipboard_result = if let Some(ref clipboard) = self.clipboard {
501 tracing::trace!("Attempting to read from clipboard...");
502 Some(clipboard.lock().unwrap().get_text())
503 } else {
504 None
505 };
506
507 match clipboard_result {
508 Some(Ok(text)) if !text.is_empty() => {
509 tracing::trace!("Read {} chars from clipboard", text.len());
510 self.insert_paste(&text);
511 }
512 Some(Ok(_)) => {
513 tracing::trace!("Clipboard is empty");
514 }
515 Some(Err(e)) => {
516 tracing::debug!("✗ Failed to read clipboard: {}", e);
517 self.status_message = format!("Could not read clipboard: {}", e);
518 self.status_is_error = true;
519 }
520 None => {
521 tracing::debug!("✗ Clipboard not available (None)");
522 self.status_message = "Clipboard not available".to_string();
523 self.status_is_error = true;
524 }
525 }
526 return Ok(());
527 }
528
529 let autocomplete_active = self.autocomplete_manager.is_active();
531 tracing::trace!(
532 "Key handling: autocomplete_active={}, is_busy={}, history_len={}",
533 autocomplete_active,
534 self.tui_bridge.is_busy(),
535 self.input_editor.history().len()
536 );
537
538 if autocomplete_active {
539 match key.code {
540 KeyCode::Up => {
541 self.autocomplete_manager.navigate_up();
542 return Ok(());
543 }
544 KeyCode::Down => {
545 self.autocomplete_manager.navigate_down();
546 return Ok(());
547 }
548 KeyCode::Enter | KeyCode::Tab => {
549 self.accept_file_completion();
550 return Ok(());
551 }
552 KeyCode::Esc => {
553 self.autocomplete_manager.deactivate();
554 return Ok(());
555 }
556 _ => {}
557 }
558 }
559
560 if key.code == KeyCode::Esc {
562 if self.autocomplete_manager.is_active() {
564 self.autocomplete_manager.deactivate();
565 } else if self.tui_bridge.is_busy() {
566 let now = Instant::now();
568 let last_esc_time = self.input_handler.last_esc_time();
569 let should_cancel = if let Some(last_esc) = last_esc_time {
570 now.duration_since(last_esc) < Duration::from_millis(1000)
571 } else {
572 false
573 };
574
575 if should_cancel {
576 self.cancel_current_operation();
577 } else {
578 self.status_message = "Press ESC again to cancel".to_string();
580 self.status_is_error = false;
581 self.input_handler.set_last_esc_time(now);
582 }
583 } else {
584 tracing::debug!("Esc pressed, exiting");
585 self.running = false;
586 }
587 return Ok(());
588 }
589
590 let term_height = self.terminal.size().map(|s| s.height).unwrap_or(24);
593 let viewport_height = term_height
594 .saturating_sub(1) .saturating_sub(7); match key.code {
597 KeyCode::PageUp => {
598 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
599 chat.scroll_page_up(viewport_height);
600 return Ok(());
601 }
602 KeyCode::PageDown => {
603 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
604 chat.scroll_page_down(viewport_height);
605 return Ok(());
606 }
607 KeyCode::Up => {
608 tracing::debug!(
610 "Up arrow: navigating history up, history_len={}",
611 self.input_editor.history().len()
612 );
613 let navigated = self.input_editor.navigate_history_up();
614 tracing::debug!(
615 "Up arrow: navigated={}, text='{}'",
616 navigated,
617 self.input_editor.text()
618 );
619 return Ok(());
620 }
621 KeyCode::Down => {
622 tracing::debug!(
624 "Down arrow: navigating history down, is_navigating={}",
625 self.input_editor.is_navigating_history()
626 );
627 let navigated = self.input_editor.navigate_history_down();
628 tracing::debug!(
629 "Down arrow: navigated={}, text='{}'",
630 navigated,
631 self.input_editor.text()
632 );
633 return Ok(());
634 }
635 _ => {}
636 }
637
638 if self.tui_bridge.is_busy() {
640 match key.code {
642 KeyCode::Char(c)
643 if key.modifiers == KeyModifiers::NONE
644 || key.modifiers == KeyModifiers::SHIFT =>
645 {
646 self.input_editor.insert_char(c);
648 tracing::debug!("Agent busy, queuing character: {}", c);
649 }
650 KeyCode::Backspace => {
651 self.delete_char_before_cursor();
652 }
653 KeyCode::Delete => {
654 self.input_editor.delete_char_at();
655 }
656 KeyCode::Left => {
657 self.input_editor.move_left();
658 }
659 KeyCode::Right => {
660 self.input_editor.move_right();
661 }
662 KeyCode::Home => {
663 self.input_editor.move_to_start();
664 }
665 KeyCode::End => {
666 self.input_editor.move_to_end();
667 }
668 KeyCode::Enter => {
669 let text = self.input_editor.take_and_add_to_history();
671 if !text.is_empty() {
672 self.input_queue.queue_message(text);
673 tracing::info!("Message queued while agent is busy");
674 }
675 }
676 KeyCode::Esc => {
677 if self.input_queue.has_pending_steers() {
679 self.input_queue.set_submit_after_interrupt(true);
680 self.cancel_current_operation();
681 tracing::info!("Interrupting with pending steers to send immediately");
682 }
683 }
684 _ => {}
685 }
686 return Ok(());
687 }
688
689 if self.handle_backspace(&key) {
691 tracing::debug!("Backspace handled, input: {:?}", self.input_editor.text());
692 return Ok(());
693 }
694
695 match key.code {
696 KeyCode::Delete => {
697 if self.input_editor.delete_char_at() {
698 tracing::debug!("Delete: input now: {:?}", self.input_editor.text());
699 }
700 }
701 KeyCode::Left => {
702 tracing::debug!(
703 "KeyCode::Left: has_pasted_content={}",
704 self.input_editor.has_pasted_content()
705 );
706 self.input_editor.move_left();
707 }
708 KeyCode::Right => {
709 tracing::debug!(
710 "KeyCode::Right: has_pasted_content={}",
711 self.input_editor.has_pasted_content()
712 );
713 self.input_editor.move_right();
714 }
715 KeyCode::Home => {
716 self.input_editor.move_to_start();
717 }
718 KeyCode::End => {
719 self.input_editor.move_to_end();
720 }
721 KeyCode::Enter => {
722 self.handle_enter()?;
724 }
725 KeyCode::Char(c)
727 if key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT =>
728 {
729 if c == '@' {
731 self.input_editor.insert_char('@');
733
734 self.activate_file_autocomplete();
736 } else if self.autocomplete_manager.is_active() {
737 self.autocomplete_manager.append_char(c);
739 self.input_editor.insert_char(c);
740 } else {
741 self.input_editor.insert_char(c);
743 }
744 }
745 _ => {
746 }
748 }
749
750 Ok(())
751 }
752
753 fn handle_backspace(&mut self, key: &KeyEvent) -> bool {
755 if key.code == KeyCode::Backspace {
757 tracing::debug!("Backspace detected via KeyCode::Backspace");
758 self.delete_char_before_cursor();
759 return true;
760 }
761
762 if key.code == KeyCode::Char('h') && key.modifiers == KeyModifiers::CONTROL {
764 tracing::debug!("Backspace detected via Ctrl+H");
765 self.delete_char_before_cursor();
766 return true;
767 }
768
769 if let KeyCode::Char(c) = key.code {
771 if c == '\x7f' || c == '\x08' {
772 tracing::debug!("Backspace detected via char code: {}", c as u8);
773 self.delete_char_before_cursor();
774 return true;
775 }
776 }
777
778 false
779 }
780
781 fn delete_char_before_cursor(&mut self) {
782 tracing::debug!(
783 "delete_char: cursor={}, len={}, input={:?}",
784 self.input_editor.cursor(),
785 self.input_editor.text().len(),
786 self.input_editor.text()
787 );
788
789 if self.autocomplete_manager.is_active() {
791 let should_close = self.autocomplete_manager.backspace();
792
793 if should_close
794 && self.input_editor.cursor() > 0
795 && self.input_editor.char_before_cursor() == Some('@')
796 {
797 self.input_editor.delete_char_before();
798 self.autocomplete_manager.deactivate();
799 return;
800 }
801 }
802
803 self.input_editor.delete_char_before();
805 }
806
807 fn activate_file_autocomplete(&mut self) {
809 let trigger_pos = self.input_editor.cursor() - 1; self.autocomplete_manager.activate(trigger_pos);
811
812 tracing::info!(
813 "🔍 ACTIVATED AUTOCOMPLETE: trigger_pos={}, cursor={}, text='{}'",
814 trigger_pos,
815 self.input_editor.cursor(),
816 self.input_editor.text()
817 );
818 }
819
820 fn accept_file_completion(&mut self) {
822 let selected = self.autocomplete_manager.selected_match().cloned();
825
826 if let Some(selected) = selected {
827 let trigger_pos = self.autocomplete_manager.trigger_pos().unwrap_or(0);
828 let current_cursor = self.input_editor.cursor();
829
830 tracing::info!(
831 "🎯 ACCEPT: trigger_pos={}, cursor={}, path='{}'",
832 trigger_pos,
833 current_cursor,
834 selected.path
835 );
836
837 let remove_start = trigger_pos + 1;
839 let remove_end = current_cursor;
840
841 tracing::info!(
842 "🎯 REMOVE RANGE: {}..{} = '{}'",
843 remove_start,
844 remove_end,
845 &self.input_editor.text()
846 [remove_start..remove_end.min(self.input_editor.text().len())]
847 );
848
849 if remove_end > remove_start {
852 self.input_editor
853 .replace_range(remove_start, remove_end, &selected.path);
854 } else {
855 self.input_editor.set_cursor(remove_start);
857 self.input_editor.insert_str(&selected.path);
858 }
859
860 self.input_editor.insert_char(' ');
862
863 tracing::info!(
864 "🎯 FINAL: '{}', cursor={}",
865 self.input_editor.text(),
866 self.input_editor.cursor()
867 );
868 }
869
870 self.autocomplete_manager.deactivate();
872 }
873
874 fn handle_enter(&mut self) -> Result<(), CliError> {
875 let text = self.input_editor.take_and_add_to_history();
876
877 if text.is_empty() {
878 return Ok(());
879 }
880
881 tracing::info!("Enter pressed with text: {:?}", text);
882
883 if text.starts_with('/') {
885 use crate::tui::commands::{CommandContext, CommandResult};
886
887 let mut cmd_ctx = CommandContext::new(
888 self.tui_bridge.chat_view().clone(),
889 self.tui_bridge.session_manager(),
890 self.tui_bridge.session_id(),
891 self.tui_bridge.state_arc(),
892 self.tui_bridge.messages(),
893 self.tui_bridge.total_input_tokens_arc(),
894 self.tui_bridge.total_output_tokens_arc(),
895 self.clipboard.clone(),
896 self.autocomplete_manager.base_path().to_path_buf(),
897 );
898
899 match self.command_registry.parse_and_execute(&text, &mut cmd_ctx) {
901 Ok(Some(result)) => {
902 self.tui_bridge
904 .session_id_arc()
905 .lock()
906 .unwrap()
907 .clone_from(&cmd_ctx.session_id);
908
909 match result {
910 CommandResult::Exit => {
911 self.running = false;
912 return Ok(());
913 }
914 CommandResult::ClearChat => {
915 return Ok(());
917 }
918 CommandResult::NewSession | CommandResult::LoadSession(_) => {
919 *self.tui_bridge.messages().lock().unwrap() =
921 cmd_ctx.messages.lock().unwrap().clone();
922 *self.tui_bridge.total_input_tokens_arc().lock().unwrap() =
923 *cmd_ctx.total_input_tokens.lock().unwrap();
924 *self.tui_bridge.total_output_tokens_arc().lock().unwrap() =
925 *cmd_ctx.total_output_tokens.lock().unwrap();
926 return Ok(());
927 }
928 CommandResult::Continue
929 | CommandResult::Message(_)
930 | CommandResult::Share(_) => {
931 return Ok(());
932 }
933 }
934 }
935 Ok(None) => {
936 }
938 Err(e) => {
939 tracing::error!("Command error: {}", e);
940 self.tui_bridge
942 .chat_view()
943 .lock()
944 .unwrap()
945 .add_message(Message::system(format!("Error: {}", e)));
946 return Ok(());
947 }
948 }
949 }
950
951 let text_lower = text.to_lowercase();
953 if text_lower == "exit" || text_lower == "quit" {
954 tracing::info!("Exit command detected, exiting");
955 self.running = false;
956 return Ok(());
957 }
958
959 if text_lower == "clear" {
960 tracing::info!("Clear command detected");
961 self.tui_bridge.chat_view().lock().unwrap().clear();
962 return Ok(());
963 }
964
965 if text_lower == "help" {
966 tracing::info!("Help command detected");
967 let help_msg = Message::system(
968 "Available commands:\n\
969 /help - Show this help message\n\
970 /clear - Clear chat history\n\
971 /exit - Exit the application\n\
972 /quit - Exit the application\n\
973 /session list - List all sessions\n\
974 /session new - Create a new session\n\
975 /session load <id> - Load a session by ID\n\
976 /share - Copy session to clipboard (markdown)\n\
977 /share md - Export session as markdown file\n\
978 /share json - Export session as JSON file\n\
979 \n\
980 Page Up/Down - Scroll chat history\n\
981 Up/Down (empty input) - Navigate input history"
982 .to_string(),
983 );
984 self.tui_bridge
985 .chat_view()
986 .lock()
987 .unwrap()
988 .add_message(help_msg);
989 return Ok(());
990 }
991
992 let _content = if self.pending_images.is_empty() {
995 limit_llm::MessageContent::text(text.clone())
996 } else {
997 let mut parts = vec![limit_llm::ContentPart::text(text.clone())];
999
1000 for image_path in self.pending_images.drain(..) {
1001 match std::fs::read(&image_path) {
1003 Ok(image_data) => {
1004 let media_type = image_path
1006 .extension()
1007 .and_then(|e| e.to_str())
1008 .map(|e| match e.to_lowercase().as_str() {
1009 "png" => "image/png",
1010 "jpg" | "jpeg" => "image/jpeg",
1011 "gif" => "image/gif",
1012 "webp" => "image/webp",
1013 _ => "image/png",
1014 })
1015 .unwrap_or("image/png");
1016
1017 let base64_data = base64::Engine::encode(
1018 &base64::engine::general_purpose::STANDARD,
1019 &image_data,
1020 );
1021
1022 parts.push(limit_llm::ContentPart::image_base64(
1023 media_type,
1024 &base64_data,
1025 ));
1026
1027 tracing::info!(
1028 "Attached image: {} ({} bytes, {})",
1029 image_path.display(),
1030 image_data.len(),
1031 media_type
1032 );
1033 }
1034 Err(e) => {
1035 tracing::error!("Failed to read image {}: {}", image_path.display(), e);
1036 }
1037 }
1038 }
1039
1040 self.status_message = "Ready - Type a message and press Enter".to_string();
1041 limit_llm::MessageContent::parts(parts)
1042 };
1043
1044 self.tui_bridge.add_user_message(text.clone());
1045
1046 let operation_id = self.tui_bridge.next_operation_id();
1048 tracing::debug!("handle_enter: new operation_id={}", operation_id);
1049 self.tui_bridge.set_state(TuiState::Idle);
1050
1051 let cancel_token = tokio_util::sync::CancellationToken::new();
1053 self.cancellation_token = Some(cancel_token.clone());
1054
1055 let messages = self.tui_bridge.messages();
1057 let agent_bridge = self.tui_bridge.agent_bridge_arc();
1058 let session_manager = self.tui_bridge.session_manager();
1059 let session_id = self.tui_bridge.session_id();
1060 let total_input_tokens = self.tui_bridge.total_input_tokens_arc();
1061 let total_output_tokens = self.tui_bridge.total_output_tokens_arc();
1062
1063 tracing::debug!("Spawning LLM processing thread");
1064
1065 std::thread::spawn(move || {
1067 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1068 let rt = tokio::runtime::Runtime::new().unwrap();
1070
1071 #[allow(clippy::await_holding_lock)]
1073 rt.block_on(async {
1074 if cancel_token.is_cancelled() {
1076 tracing::debug!("Operation cancelled before acquiring locks");
1077 return;
1078 }
1079
1080 let messages_guard = {
1082 let mut attempts = 0;
1083 loop {
1084 if cancel_token.is_cancelled() {
1085 tracing::debug!(
1086 "Operation cancelled while waiting for messages lock"
1087 );
1088 return;
1089 }
1090 match messages.try_lock() {
1091 Ok(guard) => break guard,
1092 Err(std::sync::TryLockError::WouldBlock) => {
1093 attempts += 1;
1094 if attempts > 50 {
1095 tracing::error!("Timeout waiting for messages lock");
1096 return;
1097 }
1098 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1099 }
1100 Err(e) => {
1101 tracing::error!("Failed to lock messages: {}", e);
1102 return;
1103 }
1104 }
1105 }
1106 };
1107
1108 let mut messages_guard = messages_guard;
1109
1110 if cancel_token.is_cancelled() {
1112 tracing::debug!("Operation cancelled before acquiring bridge lock");
1113 return;
1114 }
1115
1116 let bridge_guard = {
1117 let mut attempts = 0;
1118 loop {
1119 if cancel_token.is_cancelled() {
1120 tracing::debug!(
1121 "Operation cancelled while waiting for bridge lock"
1122 );
1123 return;
1124 }
1125 match agent_bridge.try_lock() {
1126 Ok(guard) => break guard,
1127 Err(std::sync::TryLockError::WouldBlock) => {
1128 attempts += 1;
1129 if attempts > 50 {
1130 tracing::error!("Timeout waiting for bridge lock");
1131 return;
1132 }
1133 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1134 }
1135 Err(e) => {
1136 tracing::error!("Failed to lock agent_bridge: {}", e);
1137 return;
1138 }
1139 }
1140 }
1141 };
1142
1143 let mut bridge = bridge_guard;
1144
1145 bridge.set_cancellation_token(cancel_token.clone(), operation_id);
1147
1148 match bridge.process_message(&text, &mut messages_guard).await {
1149 Ok(result) => {
1150 {
1151 let mut input = total_input_tokens.lock().unwrap();
1152 let mut output = total_output_tokens.lock().unwrap();
1153 *input += result.input_tokens;
1154 *output += result.output_tokens;
1155 }
1156
1157 let msgs = messages_guard.clone();
1158 let input_tokens = *total_input_tokens.lock().unwrap();
1159 let output_tokens = *total_output_tokens.lock().unwrap();
1160
1161 if let Err(e) = session_manager.lock().unwrap().save_session(
1162 &session_id,
1163 &msgs,
1164 input_tokens,
1165 output_tokens,
1166 ) {
1167 tracing::error!(
1168 "✗ Failed to auto-save session {}: {}",
1169 session_id,
1170 e
1171 );
1172 } else {
1173 tracing::info!(
1174 "✓ Session {} auto-saved ({} messages, {} in, {} out tokens)",
1175 session_id,
1176 msgs.len(),
1177 input_tokens,
1178 output_tokens
1179 );
1180 }
1181 }
1182 Err(e) => {
1183 let error_msg = e.to_string();
1185 if error_msg.contains("cancelled") {
1186 tracing::info!("Request cancelled by user");
1187 } else {
1188 tracing::error!("LLM error: {}", e);
1189 }
1190 }
1191 }
1192
1193 bridge.clear_cancellation_token();
1195 });
1196 }));
1197
1198 if let Err(panic_payload) = result {
1199 let msg = panic_payload
1200 .downcast_ref::<&str>()
1201 .copied()
1202 .or_else(|| panic_payload.downcast_ref::<String>().map(|s| s.as_str()))
1203 .unwrap_or("unknown panic");
1204 tracing::error!("LLM thread panicked: {}", msg);
1205 }
1206 });
1207
1208 Ok(())
1209 }
1210
1211 fn cancel_current_operation(&mut self) {
1213 if let Some(ref token) = self.cancellation_token {
1214 token.cancel();
1215 tracing::debug!("Cancellation token triggered");
1216
1217 self.tui_bridge.next_operation_id();
1219
1220 let should_send_steers = self.input_queue.should_submit_after_interrupt();
1222
1223 if should_send_steers {
1224 if let Some(merged) = self.input_queue.merge_all() {
1226 tracing::info!(
1227 "Sending pending steers immediately after interrupt: {}",
1228 merged
1229 );
1230
1231 self.tui_bridge.add_user_message(merged.clone());
1233
1234 self.input_editor.set_text(&merged);
1237 self.status_message =
1238 "Steers restored to input - press Enter to send".to_string();
1239 }
1240 self.input_queue.set_submit_after_interrupt(false);
1241 } else {
1242 self.tui_bridge.set_state(TuiState::Idle);
1244
1245 self.status_message = "Operation cancelled".to_string();
1247 self.status_is_error = false;
1248
1249 self.tui_bridge
1251 .activity_feed()
1252 .lock()
1253 .unwrap()
1254 .complete_all();
1255
1256 let cancel_msg = Message::system("⚠ Operation cancelled by user".to_string());
1258 self.tui_bridge
1259 .chat_view()
1260 .lock()
1261 .unwrap()
1262 .add_message(cancel_msg);
1263 }
1264 }
1265 self.cancellation_token = None;
1266 self.input_handler.reset_esc_time();
1268 }
1269
1270 fn draw(&mut self) -> Result<(), CliError> {
1271 let chat_view = self.tui_bridge.chat_view().clone();
1272 let display_text = self.input_editor.display_text_combined();
1273 let cursor_pos = self.input_editor.cursor();
1274 let cursor_blink_state = self.input_handler.cursor_blink_state();
1275 let tui_bridge = &self.tui_bridge;
1276 let file_autocomplete = self.autocomplete_manager.to_legacy_state();
1277
1278 let pending_input_preview =
1280 if self.input_queue.has_queued_messages() || self.input_queue.has_pending_steers() {
1281 let mut preview = limit_tui::components::PendingInputPreview::new();
1282 preview.pending_steers = self.input_queue.steer_texts();
1283 preview.queued_messages = self.input_queue.queued_texts();
1284 Some(preview)
1285 } else {
1286 None
1287 };
1288
1289 self.terminal
1290 .draw(|f| {
1291 UiRenderer::render(
1292 f,
1293 f.area(),
1294 &chat_view,
1295 &display_text,
1296 cursor_pos,
1297 &self.status_message,
1298 self.status_is_error,
1299 cursor_blink_state,
1300 tui_bridge,
1301 &file_autocomplete,
1302 pending_input_preview.as_ref(),
1303 );
1304 })
1305 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
1306
1307 Ok(())
1308 }
1309
1310 fn maybe_send_next_queued_input(&mut self) {
1312 if self.input_queue.is_autosend_suppressed() {
1313 return;
1314 }
1315
1316 if self.tui_bridge.is_busy() {
1317 return;
1318 }
1319
1320 if let Some(msg) = self.input_queue.pop_queued() {
1322 tracing::info!("Sending queued message: {}", msg.text);
1323
1324 self.input_editor.clear();
1326 for ch in msg.text.chars() {
1327 self.input_editor.insert_char(ch);
1328 }
1329
1330 self.input_editor.history_mut().add(&msg.text);
1332
1333 drop(self.handle_enter());
1336 }
1337 }
1338}
1339
1340#[cfg(test)]
1341mod tests {
1342 use super::*;
1343 use crate::agent_bridge::AgentBridge;
1344 use crate::tui::bridge::TuiBridge;
1345 use std::io::IsTerminal;
1346 use tokio::sync::mpsc;
1347
1348 fn create_test_config() -> limit_llm::Config {
1350 use limit_llm::{BrowserConfigSection, ProviderConfig};
1351 let mut providers = std::collections::HashMap::new();
1352 providers.insert(
1353 "anthropic".to_string(),
1354 ProviderConfig {
1355 api_key: Some("test-key".to_string()),
1356 model: "claude-3-5-sonnet-20241022".to_string(),
1357 base_url: None,
1358 max_tokens: 4096,
1359 timeout: 60,
1360 max_iterations: 100,
1361 thinking_enabled: false,
1362 clear_thinking: true,
1363 },
1364 );
1365 limit_llm::Config {
1366 provider: "anthropic".to_string(),
1367 providers,
1368 browser: BrowserConfigSection::default(),
1369 compaction: limit_llm::CompactionSettings::default(),
1370 cache: limit_llm::CacheSettings::default(),
1371 }
1372 }
1373
1374 #[test]
1375 fn test_tui_app_new() {
1376 if std::io::stdout().is_terminal() {
1378 let config = create_test_config();
1379 let agent_bridge = AgentBridge::new(config).unwrap();
1380 let (_tx, rx) = mpsc::unbounded_channel();
1381
1382 let tui_bridge = TuiBridge::new(agent_bridge, rx).unwrap();
1383 let app = TuiApp::new(tui_bridge);
1384 assert!(app.is_ok());
1385 }
1386 }
1387}