1use crate::agent_bridge::{AgentBridge, AgentEvent};
2use crate::clipboard::ClipboardManager;
3use crate::error::CliError;
4use crate::file_finder::FileFinder;
5use crate::session::SessionManager;
6use crossterm::event::{
7 self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
8 Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind,
9};
10use crossterm::execute;
11use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
12use limit_tui::components::{
13 calculate_popup_area, ActivityFeed, ChatView, FileAutocompleteWidget, FileMatchData, Message,
14 Spinner,
15};
16use ratatui::{
17 backend::CrosstermBackend,
18 layout::{Constraint, Direction, Layout},
19 style::{Color, Modifier, Style},
20 text::{Line, Span},
21 widgets::{Block, Borders, Paragraph, Wrap},
22 Frame, Terminal,
23};
24use std::io;
25use std::sync::{Arc, Mutex};
26use tokio::sync::mpsc;
27
28const MAX_PASTE_SIZE: usize = 100 * 1024;
30
31#[derive(Debug, Clone)]
33pub struct FileAutocompleteState {
34 pub is_active: bool,
36 pub query: String,
38 pub trigger_pos: usize,
40 pub matches: Vec<FileMatchData>,
42 pub selected_index: usize,
44}
45
46fn debug_log(msg: &str) {
48 use std::fs::OpenOptions;
49 use std::io::Write;
50 if let Ok(mut file) = OpenOptions::new()
51 .create(true)
52 .append(true)
53 .open(std::env::var("HOME").unwrap_or_else(|_| ".".to_string()) + "/.limit/logs/tui.log")
54 {
55 let timestamp = chrono::Local::now().format("%H:%M:%S%.3f");
56 let _ = writeln!(file, "[{}] {}", timestamp, msg);
57 }
58}
59#[derive(Debug, Clone, PartialEq, Default)]
61pub enum TuiState {
62 #[default]
63 Idle,
64 Thinking,
65}
66
67pub struct TuiBridge {
69 agent_bridge: Arc<Mutex<AgentBridge>>,
71 event_rx: mpsc::UnboundedReceiver<AgentEvent>,
73 state: Arc<Mutex<TuiState>>,
75 chat_view: Arc<Mutex<ChatView>>,
77 activity_feed: Arc<Mutex<ActivityFeed>>,
79 spinner: Arc<Mutex<Spinner>>,
82 messages: Arc<Mutex<Vec<limit_llm::Message>>>,
84 total_input_tokens: Arc<Mutex<u64>>,
86 total_output_tokens: Arc<Mutex<u64>>,
88 session_manager: Arc<Mutex<SessionManager>>,
90 session_id: Arc<Mutex<String>>,
92}
93
94impl TuiBridge {
95 pub fn new(
97 agent_bridge: AgentBridge,
98 event_rx: mpsc::UnboundedReceiver<AgentEvent>,
99 ) -> Result<Self, CliError> {
100 let session_manager = SessionManager::new().map_err(|e| {
101 CliError::ConfigError(format!("Failed to create session manager: {}", e))
102 })?;
103
104 let session_id = session_manager
106 .create_new_session()
107 .map_err(|e| CliError::ConfigError(format!("Failed to create session: {}", e)))?;
108 tracing::info!("Created new TUI session: {}", session_id);
109
110 let messages: Vec<limit_llm::Message> = Vec::new();
112
113 let sessions = session_manager.list_sessions().unwrap_or_default();
115 let session_info = sessions.iter().find(|s| s.id == session_id);
116 let initial_input = session_info.map(|s| s.total_input_tokens).unwrap_or(0);
117 let initial_output = session_info.map(|s| s.total_output_tokens).unwrap_or(0);
118
119 let chat_view = Arc::new(Mutex::new(ChatView::new()));
120
121 for msg in &messages {
123 match msg.role {
124 limit_llm::Role::User => {
125 let chat_msg = Message::user(msg.content.clone().unwrap_or_default());
126 chat_view.lock().unwrap().add_message(chat_msg);
127 }
128 limit_llm::Role::Assistant => {
129 let content = msg.content.clone().unwrap_or_default();
130 let chat_msg = Message::assistant(content);
131 chat_view.lock().unwrap().add_message(chat_msg);
132 }
133 limit_llm::Role::System => {
134 }
136 limit_llm::Role::Tool => {
137 }
139 }
140 }
141
142 tracing::info!("Loaded {} messages into chat view", messages.len());
143
144 let session_short_id = format!("...{}", &session_id[session_id.len().saturating_sub(8)..]);
146 let welcome_msg =
147 Message::system(format!("🆕 New TUI session started: {}", session_short_id));
148 chat_view.lock().unwrap().add_message(welcome_msg);
149
150 let model_name = agent_bridge.model().to_string();
152 if !model_name.is_empty() {
153 let model_msg = Message::system(format!("Using model: {}", model_name));
154 chat_view.lock().unwrap().add_message(model_msg);
155 }
156
157 Ok(Self {
158 agent_bridge: Arc::new(Mutex::new(agent_bridge)),
159 event_rx,
160 state: Arc::new(Mutex::new(TuiState::Idle)),
161 chat_view,
162 activity_feed: Arc::new(Mutex::new(ActivityFeed::new())),
163 spinner: Arc::new(Mutex::new(Spinner::new("Thinking..."))),
164 messages: Arc::new(Mutex::new(messages)),
165 total_input_tokens: Arc::new(Mutex::new(initial_input)),
166 total_output_tokens: Arc::new(Mutex::new(initial_output)),
167 session_manager: Arc::new(Mutex::new(session_manager)),
168 session_id: Arc::new(Mutex::new(session_id)),
169 })
170 }
171
172 pub fn agent_bridge_arc(&self) -> Arc<Mutex<AgentBridge>> {
174 self.agent_bridge.clone()
175 }
176
177 #[allow(dead_code)]
179 pub fn agent_bridge(&self) -> std::sync::MutexGuard<'_, AgentBridge> {
180 self.agent_bridge.lock().unwrap()
181 }
182
183 pub fn state(&self) -> TuiState {
185 self.state.lock().unwrap().clone()
186 }
187
188 pub fn chat_view(&self) -> &Arc<Mutex<ChatView>> {
190 &self.chat_view
191 }
192
193 pub fn spinner(&self) -> &Arc<Mutex<Spinner>> {
195 &self.spinner
196 }
197
198 pub fn activity_feed(&self) -> &Arc<Mutex<ActivityFeed>> {
200 &self.activity_feed
201 }
202
203 pub fn process_events(&mut self) -> Result<(), CliError> {
205 while let Ok(event) = self.event_rx.try_recv() {
206 match event {
207 AgentEvent::Thinking => {
208 *self.state.lock().unwrap() = TuiState::Thinking;
209 }
210 AgentEvent::ToolStart { name, args } => {
211 let activity_msg = Self::format_activity_message(&name, &args);
212 self.activity_feed.lock().unwrap().add(activity_msg, true);
214 }
215 AgentEvent::ToolComplete { name: _, result: _ } => {
216 self.activity_feed.lock().unwrap().complete_current();
218 }
219 AgentEvent::ContentChunk(chunk) => {
220 self.chat_view
221 .lock()
222 .unwrap()
223 .append_to_last_assistant(&chunk);
224 }
225 AgentEvent::Done => {
226 *self.state.lock().unwrap() = TuiState::Idle;
227 self.activity_feed.lock().unwrap().complete_all();
229 }
230 AgentEvent::Error(err) => {
231 *self.state.lock().unwrap() = TuiState::Idle;
233 let chat_msg = Message::system(format!("Error: {}", err));
234 self.chat_view.lock().unwrap().add_message(chat_msg);
235 }
236 AgentEvent::TokenUsage {
237 input_tokens,
238 output_tokens,
239 } => {
240 *self.total_input_tokens.lock().unwrap() += input_tokens;
242 *self.total_output_tokens.lock().unwrap() += output_tokens;
243 }
244 }
245 }
246 Ok(())
247 }
248
249 fn format_activity_message(tool_name: &str, args: &serde_json::Value) -> String {
250 match tool_name {
251 "file_read" => args
252 .get("path")
253 .and_then(|p| p.as_str())
254 .map(|p| format!("Reading {}...", Self::truncate_path(p, 40)))
255 .unwrap_or_else(|| "Reading file...".to_string()),
256 "file_write" => args
257 .get("path")
258 .and_then(|p| p.as_str())
259 .map(|p| format!("Writing {}...", Self::truncate_path(p, 40)))
260 .unwrap_or_else(|| "Writing file...".to_string()),
261 "file_edit" => args
262 .get("path")
263 .and_then(|p| p.as_str())
264 .map(|p| format!("Editing {}...", Self::truncate_path(p, 40)))
265 .unwrap_or_else(|| "Editing file...".to_string()),
266 "bash" => args
267 .get("command")
268 .and_then(|c| c.as_str())
269 .map(|c| format!("Running {}...", Self::truncate_command(c, 30)))
270 .unwrap_or_else(|| "Executing command...".to_string()),
271 "git_status" => "Checking git status...".to_string(),
272 "git_diff" => "Checking git diff...".to_string(),
273 "git_log" => "Checking git log...".to_string(),
274 "git_add" => "Staging files...".to_string(),
275 "git_commit" => "Creating commit...".to_string(),
276 "git_push" => "Pushing to remote...".to_string(),
277 "git_pull" => "Pulling from remote...".to_string(),
278 "git_clone" => args
279 .get("url")
280 .and_then(|u| u.as_str())
281 .map(|u| format!("Cloning {}...", Self::truncate_path(u, 40)))
282 .unwrap_or_else(|| "Cloning repository...".to_string()),
283 "grep" => args
284 .get("pattern")
285 .and_then(|p| p.as_str())
286 .map(|p| format!("Searching for '{}'...", Self::truncate_command(p, 30)))
287 .unwrap_or_else(|| "Searching...".to_string()),
288 "ast_grep" => args
289 .get("pattern")
290 .and_then(|p| p.as_str())
291 .map(|p| format!("AST searching '{}'...", Self::truncate_command(p, 25)))
292 .unwrap_or_else(|| "AST searching...".to_string()),
293 "lsp" => args
294 .get("command")
295 .and_then(|c| c.as_str())
296 .map(|c| format!("Running LSP {}...", c))
297 .unwrap_or_else(|| "Running LSP...".to_string()),
298 _ => format!("Executing {}...", tool_name),
299 }
300 }
301
302 fn truncate_path(s: &str, max_len: usize) -> String {
303 if s.len() <= max_len {
304 s.to_string()
305 } else {
306 format!("...{}", &s[s.len().saturating_sub(max_len - 3)..])
307 }
308 }
309
310 fn truncate_command(s: &str, max_len: usize) -> String {
311 if s.len() <= max_len {
312 s.to_string()
313 } else {
314 format!("{}...", &s[..max_len.saturating_sub(3)])
315 }
316 }
317
318 pub fn add_user_message(&self, content: String) {
320 let msg = Message::user(content);
321 self.chat_view.lock().unwrap().add_message(msg);
322 }
323
324 pub fn tick_spinner(&self) {
326 self.spinner.lock().unwrap().tick();
327 }
328
329 pub fn is_busy(&self) -> bool {
331 !matches!(self.state(), TuiState::Idle)
332 }
333
334 pub fn total_input_tokens(&self) -> u64 {
336 self.total_input_tokens
337 .lock()
338 .map(|guard| *guard)
339 .unwrap_or(0)
340 }
341
342 pub fn total_output_tokens(&self) -> u64 {
344 self.total_output_tokens
345 .lock()
346 .map(|guard| *guard)
347 .unwrap_or(0)
348 }
349
350 pub fn session_id(&self) -> String {
352 self.session_id
353 .lock()
354 .map(|guard| guard.clone())
355 .unwrap_or_else(|_| String::from("unknown"))
356 }
357
358 pub fn save_session(&self) -> Result<(), CliError> {
360 let session_id = self
361 .session_id
362 .lock()
363 .map(|guard| guard.clone())
364 .unwrap_or_else(|_| String::from("unknown"));
365
366 let messages = self
367 .messages
368 .lock()
369 .map(|guard| guard.clone())
370 .unwrap_or_default();
371
372 let input_tokens = self
373 .total_input_tokens
374 .lock()
375 .map(|guard| *guard)
376 .unwrap_or(0);
377
378 let output_tokens = self
379 .total_output_tokens
380 .lock()
381 .map(|guard| *guard)
382 .unwrap_or(0);
383
384 tracing::debug!(
385 "Saving session {} with {} messages, {} in tokens, {} out tokens",
386 session_id,
387 messages.len(),
388 input_tokens,
389 output_tokens
390 );
391
392 let session_manager = self.session_manager.lock().map_err(|e| {
393 CliError::ConfigError(format!("Failed to acquire session manager lock: {}", e))
394 })?;
395
396 session_manager.save_session(&session_id, &messages, input_tokens, output_tokens)?;
397 tracing::info!(
398 "✓ Session {} saved successfully ({} messages, {} in tokens, {} out tokens)",
399 session_id,
400 messages.len(),
401 input_tokens,
402 output_tokens
403 );
404 Ok(())
405 }
406}
407
408pub struct TuiApp {
410 tui_bridge: TuiBridge,
411 terminal: Terminal<CrosstermBackend<io::Stdout>>,
412 running: bool,
413 input_text: String,
414 cursor_pos: usize,
415 status_message: String,
416 status_is_error: bool,
417 cursor_blink_state: bool,
418 cursor_blink_timer: std::time::Instant,
419 mouse_selection_start: Option<(u16, u16)>,
421 clipboard: Option<ClipboardManager>,
423 file_autocomplete: Option<FileAutocompleteState>,
425 file_finder: FileFinder,
427}
428
429impl TuiApp {
430 pub fn new(tui_bridge: TuiBridge) -> Result<Self, CliError> {
432 let backend = CrosstermBackend::new(io::stdout());
433 let terminal =
434 Terminal::new(backend).map_err(|e| CliError::IoError(io::Error::other(e)))?;
435
436 let session_id = tui_bridge.session_id();
437 tracing::info!("TUI started with session: {}", session_id);
438
439 let clipboard = match ClipboardManager::new() {
440 Ok(cb) => {
441 debug_log("✓ Clipboard initialized successfully");
442 tracing::info!("Clipboard initialized successfully");
443 Some(cb)
444 }
445 Err(e) => {
446 debug_log(&format!("✗ Clipboard initialization failed: {}", e));
447 tracing::warn!("Clipboard unavailable: {}", e);
448 None
449 }
450 };
451
452 let working_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
454 let file_finder = FileFinder::new(working_dir);
455
456 Ok(Self {
457 tui_bridge,
458 terminal,
459 running: true,
460 input_text: String::new(),
461 cursor_pos: 0,
462 status_message: "Ready - Type a message and press Enter".to_string(),
463 status_is_error: false,
464 cursor_blink_state: true,
465 cursor_blink_timer: std::time::Instant::now(),
466 mouse_selection_start: None,
467 clipboard,
468 file_autocomplete: None,
469 file_finder,
470 })
471 }
472
473 pub fn run(&mut self) -> Result<(), CliError> {
475 execute!(std::io::stdout(), EnterAlternateScreen)
477 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
478
479 execute!(std::io::stdout(), EnableMouseCapture)
481 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
482
483 execute!(std::io::stdout(), EnableBracketedPaste)
485 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
486
487 crossterm::terminal::enable_raw_mode()
488 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
489
490 struct AlternateScreenGuard;
492 impl Drop for AlternateScreenGuard {
493 fn drop(&mut self) {
494 let _ = crossterm::terminal::disable_raw_mode();
495 let _ = execute!(std::io::stdout(), DisableBracketedPaste);
496 let _ = execute!(std::io::stdout(), DisableMouseCapture);
497 let _ = execute!(std::io::stdout(), LeaveAlternateScreen);
498 }
499 }
500 let _guard = AlternateScreenGuard;
501
502 self.run_inner()
503 }
504
505 fn run_inner(&mut self) -> Result<(), CliError> {
506 while self.running {
507 self.tui_bridge.process_events()?;
509
510 if matches!(self.tui_bridge.state(), TuiState::Thinking) {
512 self.tui_bridge.tick_spinner();
513 }
514
515 self.update_status();
517
518 if crossterm::event::poll(std::time::Duration::from_millis(100))
520 .map_err(|e| CliError::IoError(io::Error::other(e)))?
521 {
522 match event::read().map_err(|e| CliError::IoError(io::Error::other(e)))? {
523 Event::Key(key) => {
524 debug_log(&format!(
526 "Event::Key - code={:?} mod={:?} kind={:?}",
527 key.code, key.modifiers, key.kind
528 ));
529
530 if key.kind == KeyEventKind::Press {
531 self.handle_key_event(key)?;
532 }
533 }
534 Event::Mouse(mouse) => {
535 match mouse.kind {
536 MouseEventKind::Down(MouseButton::Left) => {
537 debug_log(&format!(
538 "MouseDown at ({}, {})",
539 mouse.column, mouse.row
540 ));
541 self.mouse_selection_start = Some((mouse.column, mouse.row));
542 let chat = self.tui_bridge.chat_view().lock().unwrap();
544 debug_log(&format!(
545 " render_positions count: {}",
546 chat.render_position_count()
547 ));
548 if let Some((msg_idx, char_offset)) =
549 chat.screen_to_text_pos(mouse.column, mouse.row)
550 {
551 debug_log(&format!(
552 " -> Starting selection at msg={}, offset={}",
553 msg_idx, char_offset
554 ));
555 drop(chat);
556 self.tui_bridge
557 .chat_view()
558 .lock()
559 .unwrap()
560 .start_selection(msg_idx, char_offset);
561 } else {
562 debug_log(" -> No match, clearing selection");
563 drop(chat);
564 self.tui_bridge
565 .chat_view()
566 .lock()
567 .unwrap()
568 .clear_selection();
569 }
570 }
571 MouseEventKind::Drag(MouseButton::Left) => {
572 if self.mouse_selection_start.is_some() {
573 let chat = self.tui_bridge.chat_view().lock().unwrap();
575 if let Some((msg_idx, char_offset)) =
576 chat.screen_to_text_pos(mouse.column, mouse.row)
577 {
578 drop(chat);
579 self.tui_bridge
580 .chat_view()
581 .lock()
582 .unwrap()
583 .extend_selection(msg_idx, char_offset);
584 }
585 }
586 }
587 MouseEventKind::Up(MouseButton::Left) => {
588 self.mouse_selection_start = None;
589 }
590 MouseEventKind::ScrollUp => {
591 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
592 chat.scroll_up();
593 }
594 MouseEventKind::ScrollDown => {
595 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
596 chat.scroll_down();
597 }
598 _ => {}
599 }
600 }
601 Event::Paste(pasted) => {
602 if !self.tui_bridge.is_busy() {
603 self.insert_paste(&pasted);
604 }
605 }
606 _ => {}
607 }
608 } else {
609 self.tick_cursor_blink();
611 }
612
613 self.draw()?;
615 }
616
617 if let Err(e) = self.tui_bridge.save_session() {
619 tracing::error!("Failed to save session: {}", e);
620 }
621
622 Ok(())
623 }
624
625 fn update_status(&mut self) {
626 let session_id = self.tui_bridge.session_id();
627 let has_activity = self
628 .tui_bridge
629 .activity_feed()
630 .lock()
631 .unwrap()
632 .has_in_progress();
633
634 match self.tui_bridge.state() {
635 TuiState::Idle => {
636 if has_activity {
637 let spinner = self.tui_bridge.spinner().lock().unwrap();
639 self.status_message = format!("{} Processing...", spinner.current_frame());
640 } else {
641 self.status_message = format!(
642 "Ready | Session: {}",
643 session_id.chars().take(8).collect::<String>()
644 );
645 }
646 self.status_is_error = false;
647 }
648 TuiState::Thinking => {
649 let spinner = self.tui_bridge.spinner().lock().unwrap();
650 self.status_message = format!("{} Thinking...", spinner.current_frame());
651 self.status_is_error = false;
652 }
653 }
654 }
655
656 fn insert_paste(&mut self, text: &str) {
658 let text = if text.len() > MAX_PASTE_SIZE {
660 self.status_message = "Paste truncated (too large)".to_string();
661 self.status_is_error = true;
662 &text[..text
664 .char_indices()
665 .nth(MAX_PASTE_SIZE)
666 .map(|(i, _)| i)
667 .unwrap_or(text.len())]
668 } else {
669 text
670 };
671
672 let normalized = text.replace("\r", "\n");
674 self.input_text.insert_str(self.cursor_pos, &normalized);
675 self.cursor_pos += normalized.len();
676 }
677
678 fn is_copy_paste_modifier(&self, key: &KeyEvent, char: char) -> bool {
682 #[cfg(target_os = "macos")]
683 {
684 let has_super = key.modifiers.contains(KeyModifiers::SUPER);
686 let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
687 let result = key.code == KeyCode::Char(char) && (has_super || has_ctrl);
688 debug_log(&format!("is_copy_paste_modifier('{}') macOS: code={:?}, mod={:?}, super={}, ctrl={}, result={}",
689 char, key.code, key.modifiers, has_super, has_ctrl, result));
690 result
691 }
692 #[cfg(not(target_os = "macos"))]
693 {
694 let result =
695 key.code == KeyCode::Char(char) && key.modifiers.contains(KeyModifiers::CONTROL);
696 debug_log(&format!(
697 "is_copy_paste_modifier('{}') non-macOS: code={:?}, mod={:?}, ctrl={:?}, result={}",
698 char,
699 key.code,
700 key.modifiers,
701 KeyModifiers::CONTROL,
702 result
703 ));
704 result
705 }
706 }
707
708 fn tick_cursor_blink(&mut self) {
709 if self.cursor_blink_timer.elapsed().as_millis() > 500 {
711 self.cursor_blink_state = !self.cursor_blink_state;
712 self.cursor_blink_timer = std::time::Instant::now();
713 }
714 }
715
716 fn handle_key_event(&mut self, key: KeyEvent) -> Result<(), CliError> {
717 debug_log(&format!(
719 "handle_key_event: code={:?} mod={:?} kind={:?}",
720 key.code, key.modifiers, key.kind
721 ));
722
723 if matches!(key.code, KeyCode::Char('c') | KeyCode::Char('v')) {
725 debug_log(&format!(
726 ">>> SPECIAL: '{}' key detected with modifiers: {:?} (SUPER={:?}, CONTROL={:?})",
727 if matches!(key.code, KeyCode::Char('c')) {
728 'c'
729 } else {
730 'v'
731 },
732 key.modifiers,
733 key.modifiers.contains(KeyModifiers::SUPER),
734 key.modifiers.contains(KeyModifiers::CONTROL)
735 ));
736 }
737
738 if self.is_copy_paste_modifier(&key, 'c') {
740 debug_log("✓ Copy shortcut CONFIRMED - processing...");
741 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
742 let has_selection = chat.has_selection();
743 debug_log(&format!("has_selection={}", has_selection));
744
745 if has_selection {
746 if let Some(selected) = chat.get_selected_text() {
747 debug_log(&format!("Selected text length={}", selected.len()));
748 if !selected.is_empty() {
749 if let Some(ref clipboard) = self.clipboard {
750 debug_log("Attempting to copy to clipboard...");
751 match clipboard.set_text(&selected) {
752 Ok(()) => {
753 debug_log("✓ Clipboard copy successful");
754 self.status_message = "Copied to clipboard".to_string();
755 self.status_is_error = false;
756 }
757 Err(e) => {
758 debug_log(&format!("✗ Clipboard copy failed: {}", e));
759 self.status_message = format!("Clipboard error: {}", e);
760 self.status_is_error = true;
761 }
762 }
763 } else {
764 debug_log("✗ Clipboard not available (None)");
765 self.status_message = "Clipboard not available".to_string();
766 self.status_is_error = true;
767 }
768 } else {
769 debug_log("Selected text is empty");
770 }
771 chat.clear_selection();
772 } else {
773 debug_log("get_selected_text() returned None");
774 }
775 return Ok(());
776 }
777
778 debug_log("Ctrl/Cmd+C with no selection - ignoring");
780 return Ok(());
781 }
782
783 if self.is_copy_paste_modifier(&key, 'v') && !self.tui_bridge.is_busy() {
785 debug_log("✓ Paste shortcut CONFIRMED - processing...");
786 if let Some(ref clipboard) = self.clipboard {
787 debug_log("Attempting to read from clipboard...");
788 match clipboard.get_text() {
789 Ok(text) if !text.is_empty() => {
790 debug_log(&format!("Read {} chars from clipboard", text.len()));
791 self.insert_paste(&text);
792 }
793 Ok(_) => {
794 debug_log("Clipboard is empty");
795 } Err(e) => {
797 debug_log(&format!("✗ Failed to read clipboard: {}", e));
798 self.status_message = format!("Could not read clipboard: {}", e);
799 self.status_is_error = true;
800 }
801 }
802 } else {
803 debug_log("✗ Clipboard not available (None)");
804 self.status_message = "Clipboard not available".to_string();
805 self.status_is_error = true;
806 }
807 return Ok(());
808 }
809
810 if let Some(ref mut ac) = self.file_autocomplete {
812 if ac.is_active {
813 match key.code {
814 KeyCode::Up => {
815 if ac.selected_index > 0 {
816 ac.selected_index -= 1;
817 }
818 return Ok(());
819 }
820 KeyCode::Down => {
821 if ac.selected_index + 1 < ac.matches.len() {
822 ac.selected_index += 1;
823 }
824 return Ok(());
825 }
826 KeyCode::Enter | KeyCode::Tab => {
827 self.accept_file_completion();
829 return Ok(());
830 }
831 KeyCode::Esc => {
832 self.file_autocomplete = None;
834 return Ok(());
835 }
836 _ => {}
837 }
838 }
839 }
840
841 let term_height = self.terminal.size().map(|s| s.height).unwrap_or(24);
844 let viewport_height = term_height
845 .saturating_sub(1) .saturating_sub(7); match key.code {
848 KeyCode::PageUp => {
849 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
850 chat.scroll_page_up(viewport_height);
851 return Ok(());
852 }
853 KeyCode::PageDown => {
854 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
855 chat.scroll_page_down(viewport_height);
856 return Ok(());
857 }
858 KeyCode::Up => {
859 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
860 chat.scroll_up();
861 return Ok(());
862 }
863 KeyCode::Down => {
864 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
866 chat.scroll_down();
867 return Ok(());
868 }
869 _ => {}
870 }
871
872 if self.tui_bridge.is_busy() {
874 debug_log("Agent busy, ignoring");
875 return Ok(());
876 }
877
878 if self.handle_backspace(&key) {
880 debug_log(&format!("Backspace handled, input: {:?}", self.input_text));
881 return Ok(());
882 }
883
884 match key.code {
885 KeyCode::Delete => {
886 if self.cursor_pos < self.input_text.len() {
887 let next_pos = self.next_char_pos();
888 self.input_text.drain(self.cursor_pos..next_pos);
889 debug_log(&format!("Delete: input now: {:?}", self.input_text));
890 }
891 }
892 KeyCode::Left => {
893 if self.cursor_pos > 0 {
894 self.cursor_pos = self.prev_char_pos();
895 }
896 }
897 KeyCode::Right => {
898 if self.cursor_pos < self.input_text.len() {
899 self.cursor_pos = self.next_char_pos();
900 }
901 }
902 KeyCode::Home => {
903 self.cursor_pos = 0;
904 }
905 KeyCode::End => {
906 self.cursor_pos = self.input_text.len();
907 }
908 KeyCode::Enter => {
909 self.handle_enter()?;
911 }
912 KeyCode::Esc => {
913 if self.file_autocomplete.is_some() {
915 self.file_autocomplete = None;
916 } else {
917 debug_log("Esc pressed, exiting");
918 self.running = false;
919 }
920 }
921 KeyCode::Char(c)
923 if key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT =>
924 {
925 if c == '@' {
927 self.input_text.insert(self.cursor_pos, '@');
929 self.cursor_pos += 1;
930
931 self.activate_file_autocomplete();
933 } else {
934 let is_autocomplete_active = self
936 .file_autocomplete
937 .as_ref()
938 .map(|ac| ac.is_active)
939 .unwrap_or(false);
940
941 if is_autocomplete_active {
942 let query = self
944 .file_autocomplete
945 .as_ref()
946 .map(|ac| {
947 let mut q = ac.query.clone();
948 q.push(c);
949 q
950 })
951 .unwrap_or_default();
952
953 let matches = self.get_file_matches(&query);
955
956 if let Some(ref mut ac) = self.file_autocomplete {
958 ac.query.push(c);
959 ac.matches = matches;
960 ac.selected_index = 0;
961 }
962
963 self.input_text.insert(self.cursor_pos, c);
965 self.cursor_pos += c.len_utf8();
966 } else {
967 self.input_text.insert(self.cursor_pos, c);
969 self.cursor_pos += c.len_utf8();
970 }
971 }
972 }
973 _ => {
974 }
976 }
977
978 Ok(())
979 }
980
981 fn handle_backspace(&mut self, key: &KeyEvent) -> bool {
983 if key.code == KeyCode::Backspace {
985 debug_log("Backspace detected via KeyCode::Backspace");
986 self.delete_char_before_cursor();
987 return true;
988 }
989
990 if key.code == KeyCode::Char('h') && key.modifiers == KeyModifiers::CONTROL {
992 debug_log("Backspace detected via Ctrl+H");
993 self.delete_char_before_cursor();
994 return true;
995 }
996
997 if let KeyCode::Char(c) = key.code {
999 if c == '\x7f' || c == '\x08' {
1000 debug_log(&format!("Backspace detected via char code: {}", c as u8));
1001 self.delete_char_before_cursor();
1002 return true;
1003 }
1004 }
1005
1006 false
1007 }
1008
1009 fn delete_char_before_cursor(&mut self) {
1010 debug_log(&format!(
1011 "delete_char: cursor={}, len={}, input={:?}",
1012 self.cursor_pos,
1013 self.input_text.len(),
1014 self.input_text
1015 ));
1016
1017 let should_close_autocomplete = if let Some(ref ac) = self.file_autocomplete {
1019 if ac.is_active {
1020 ac.query.is_empty()
1022 } else {
1023 false
1024 }
1025 } else {
1026 false
1027 };
1028
1029 if should_close_autocomplete {
1030 if self.cursor_pos > 0 {
1032 let prev_pos = self.prev_char_pos();
1033 if &self.input_text[prev_pos..self.cursor_pos] == "@" {
1034 self.input_text.drain(prev_pos..self.cursor_pos);
1035 self.cursor_pos = prev_pos;
1036 self.file_autocomplete = None;
1037 return;
1038 }
1039 }
1040 }
1041
1042 if self
1044 .file_autocomplete
1045 .as_ref()
1046 .map(|ac| ac.is_active && !ac.query.is_empty())
1047 .unwrap_or(false)
1048 {
1049 let new_query = self
1050 .file_autocomplete
1051 .as_ref()
1052 .map(|ac| {
1053 let mut q = ac.query.clone();
1054 q.pop();
1055 q
1056 })
1057 .unwrap_or_default();
1058
1059 let matches = self.get_file_matches(&new_query);
1060
1061 if let Some(ref mut ac) = self.file_autocomplete {
1062 ac.query.pop();
1063 ac.matches = matches;
1064 ac.selected_index = 0;
1065 }
1066 }
1067
1068 if self.cursor_pos > 0 {
1069 let prev_pos = self.prev_char_pos();
1070 debug_log(&format!("draining {}..{}", prev_pos, self.cursor_pos));
1071 self.input_text.drain(prev_pos..self.cursor_pos);
1072 self.cursor_pos = prev_pos;
1073 debug_log(&format!(
1074 "after delete: cursor={}, input={:?}",
1075 self.cursor_pos, self.input_text
1076 ));
1077 } else {
1078 debug_log("cursor at 0, nothing to delete");
1079 }
1080 }
1081
1082 fn activate_file_autocomplete(&mut self) {
1084 let matches = self.get_file_matches("");
1085
1086 self.file_autocomplete = Some(FileAutocompleteState {
1087 is_active: true,
1088 query: String::new(),
1089 trigger_pos: self.cursor_pos - 1, matches,
1091 selected_index: 0,
1092 });
1093
1094 debug_log(&format!(
1095 "Activated autocomplete at pos {}",
1096 self.cursor_pos - 1
1097 ));
1098 }
1099
1100 fn get_file_matches(&mut self, query: &str) -> Vec<FileMatchData> {
1102 let files = self.file_finder.scan_files().clone();
1103 let matches = self.file_finder.filter_files(&files, query);
1104
1105 matches
1106 .into_iter()
1107 .map(|m| FileMatchData {
1108 path: m.path.to_string_lossy().to_string(),
1109 is_dir: m.is_dir,
1110 })
1111 .collect()
1112 }
1113
1114 fn accept_file_completion(&mut self) {
1116 if let Some(ref ac) = self.file_autocomplete {
1117 if let Some(selected) = ac.matches.get(ac.selected_index) {
1118 let end_pos = self.cursor_pos;
1120
1121 let remove_start = ac.trigger_pos;
1123
1124 self.input_text.drain(remove_start + 1..end_pos);
1126 self.cursor_pos = remove_start + 1;
1127
1128 self.input_text.insert_str(self.cursor_pos, &selected.path);
1130 self.cursor_pos += selected.path.len();
1131
1132 self.input_text.insert(self.cursor_pos, ' ');
1134 self.cursor_pos += 1;
1135
1136 debug_log(&format!(
1137 "Accepted completion: {} -> input now: {:?}",
1138 selected.path, self.input_text
1139 ));
1140 }
1141 }
1142
1143 self.file_autocomplete = None;
1145 }
1146
1147 fn handle_enter(&mut self) -> Result<(), CliError> {
1148 let text = self.input_text.trim().to_string();
1149
1150 self.input_text.clear();
1152 self.cursor_pos = 0;
1153
1154 if text.is_empty() {
1155 return Ok(());
1156 }
1157
1158 tracing::info!("Enter pressed with text: {:?}", text);
1159
1160 let text_lower = text.to_lowercase();
1162 if text_lower == "/exit"
1163 || text_lower == "/quit"
1164 || text_lower == "exit"
1165 || text_lower == "quit"
1166 {
1167 tracing::info!("Exit command detected, exiting");
1168 self.running = false;
1169 return Ok(());
1170 }
1171
1172 if text_lower == "/clear" || text_lower == "clear" {
1173 tracing::info!("Clear command detected");
1174 self.tui_bridge.chat_view().lock().unwrap().clear();
1175 return Ok(());
1176 }
1177
1178 if text_lower == "/help" || text_lower == "help" {
1179 tracing::info!("Help command detected");
1180 let help_msg = Message::system(
1181 "Available commands:\n\
1182 /help - Show this help message\n\
1183 /clear - Clear chat history\n\
1184 /exit - Exit the application\n\
1185 /quit - Exit the application\n\
1186 /session list - List all sessions\n\
1187 /session new - Create a new session\n\
1188 /session load <id> - Load a session by ID\n\
1189 \n\
1190 Page Up/Down - Scroll chat history"
1191 .to_string(),
1192 );
1193 self.tui_bridge
1194 .chat_view()
1195 .lock()
1196 .unwrap()
1197 .add_message(help_msg);
1198 return Ok(());
1199 }
1200
1201 if text_lower.starts_with("/session ") {
1203 let session_cmd = text.strip_prefix("/session ").unwrap();
1204 if session_cmd.trim() == "list" {
1205 self.handle_session_list()?;
1206 return Ok(());
1207 } else if session_cmd.trim() == "new" {
1208 self.handle_session_new()?;
1209 return Ok(());
1210 } else if session_cmd.starts_with("load ") {
1211 let session_id = session_cmd.strip_prefix("load ").unwrap().trim();
1212 self.handle_session_load(session_id)?;
1213 return Ok(());
1214 } else {
1215 let error_msg = Message::system(
1216 "Usage: /session list, /session new, /session load <id>".to_string(),
1217 );
1218 self.tui_bridge
1219 .chat_view()
1220 .lock()
1221 .unwrap()
1222 .add_message(error_msg);
1223 return Ok(());
1224 }
1225 }
1226
1227 self.tui_bridge.add_user_message(text.clone());
1229
1230 let messages = self.tui_bridge.messages.clone();
1232 let agent_bridge = self.tui_bridge.agent_bridge_arc();
1233 let session_manager = self.tui_bridge.session_manager.clone();
1234 let session_id = self.tui_bridge.session_id();
1235 let total_input_tokens = self.tui_bridge.total_input_tokens.clone();
1236 let total_output_tokens = self.tui_bridge.total_output_tokens.clone();
1237
1238 tracing::debug!("Spawning LLM processing thread");
1239
1240 std::thread::spawn(move || {
1242 let rt = tokio::runtime::Runtime::new().unwrap();
1244
1245 #[allow(clippy::await_holding_lock)]
1247 rt.block_on(async {
1248 let mut messages_guard = messages.lock().unwrap();
1249 let mut bridge = agent_bridge.lock().unwrap();
1250
1251 match bridge.process_message(&text, &mut messages_guard).await {
1252 Ok(_response) => {
1253 let msgs = messages_guard.clone();
1257 let input_tokens = *total_input_tokens.lock().unwrap();
1258 let output_tokens = *total_output_tokens.lock().unwrap();
1259
1260 if let Err(e) = session_manager.lock().unwrap().save_session(
1261 &session_id,
1262 &msgs,
1263 input_tokens,
1264 output_tokens,
1265 ) {
1266 tracing::error!("✗ Failed to auto-save session {}: {}", session_id, e);
1267 } else {
1268 tracing::info!(
1269 "✓ Session {} auto-saved ({} messages, {} in, {} out tokens)",
1270 session_id,
1271 msgs.len(),
1272 input_tokens,
1273 output_tokens
1274 );
1275 }
1276 }
1277 Err(e) => {
1278 tracing::error!("LLM error: {}", e);
1279 }
1280 }
1281 });
1282 });
1283
1284 Ok(())
1285 }
1286
1287 fn handle_session_list(&self) -> Result<(), CliError> {
1289 tracing::info!("Session list command detected");
1290 let session_manager = self.tui_bridge.session_manager.lock().unwrap();
1291 let current_session_id = self.tui_bridge.session_id();
1292
1293 match session_manager.list_sessions() {
1294 Ok(sessions) => {
1295 if sessions.is_empty() {
1296 let msg = Message::system("No sessions found.".to_string());
1297 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1298 } else {
1299 let mut output = vec!["Sessions (most recent first):".to_string()];
1300 for (i, session) in sessions.iter().enumerate() {
1301 let current = if session.id == current_session_id {
1302 " (current)"
1303 } else {
1304 ""
1305 };
1306 let short_id = if session.id.len() > 8 {
1307 &session.id[..8]
1308 } else {
1309 &session.id
1310 };
1311 output.push(format!(
1312 " {}. {}{} - {} messages, {} in tokens, {} out tokens",
1313 i + 1,
1314 short_id,
1315 current,
1316 session.message_count,
1317 session.total_input_tokens,
1318 session.total_output_tokens
1319 ));
1320 }
1321 let msg = Message::system(output.join("\n"));
1322 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1323 }
1324 }
1325 Err(e) => {
1326 let msg = Message::system(format!("Error listing sessions: {}", e));
1327 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1328 }
1329 }
1330 Ok(())
1331 }
1332
1333 fn handle_session_new(&mut self) -> Result<(), CliError> {
1335 tracing::info!("Session new command detected");
1336
1337 let save_result = self.tui_bridge.save_session();
1339 if let Err(e) = &save_result {
1340 tracing::error!("Failed to save current session: {}", e);
1341 let msg = Message::system(format!("⚠ Warning: Failed to save current session: {}", e));
1342 if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1343 chat.add_message(msg);
1344 }
1345 }
1346
1347 let new_session_id = {
1349 let session_manager = self.tui_bridge.session_manager.lock().map_err(|e| {
1350 CliError::ConfigError(format!("Failed to acquire session manager lock: {}", e))
1351 })?;
1352
1353 session_manager
1354 .create_new_session()
1355 .map_err(|e| CliError::ConfigError(format!("Failed to create session: {}", e)))?
1356 };
1357
1358 let old_session_id = self.tui_bridge.session_id();
1359
1360 if let Ok(mut id_guard) = self.tui_bridge.session_id.try_lock() {
1362 *id_guard = new_session_id.clone();
1363 }
1364
1365 if let Ok(mut messages_guard) = self.tui_bridge.messages.try_lock() {
1367 messages_guard.clear();
1368 }
1369 if let Ok(mut input_guard) = self.tui_bridge.total_input_tokens.try_lock() {
1370 *input_guard = 0;
1371 }
1372 if let Ok(mut output_guard) = self.tui_bridge.total_output_tokens.try_lock() {
1373 *output_guard = 0;
1374 }
1375
1376 tracing::info!(
1377 "Created new session: {} (old: {})",
1378 new_session_id,
1379 old_session_id
1380 );
1381
1382 let session_short_id = if new_session_id.len() > 8 {
1384 &new_session_id[new_session_id.len().saturating_sub(8)..]
1385 } else {
1386 &new_session_id
1387 };
1388 let msg = Message::system(format!("🆕 New session created: {}", session_short_id));
1389
1390 if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1391 chat.add_message(msg);
1392 }
1393
1394 Ok(())
1395 }
1396
1397 fn handle_session_load(&mut self, session_id: &str) -> Result<(), CliError> {
1399 tracing::info!("Session load command detected for session: {}", session_id);
1400
1401 let save_result = self.tui_bridge.save_session();
1403 if let Err(e) = &save_result {
1404 tracing::error!("Failed to save current session: {}", e);
1405 let msg = Message::system(format!("⚠ Warning: Failed to save current session: {}", e));
1406 if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1407 chat.add_message(msg);
1408 }
1409 }
1410
1411 let (full_session_id, session_info, messages) = {
1413 let session_manager = self.tui_bridge.session_manager.lock().map_err(|e| {
1414 CliError::ConfigError(format!("Failed to acquire session manager lock: {}", e))
1415 })?;
1416
1417 let sessions = session_manager
1418 .list_sessions()
1419 .map_err(|e| CliError::ConfigError(format!("Failed to list sessions: {}", e)))?;
1420
1421 let matched_session = if session_id.len() >= 8 {
1422 sessions
1424 .iter()
1425 .find(|s| s.id == session_id)
1426 .or_else(|| sessions.iter().find(|s| s.id.starts_with(session_id)))
1428 } else {
1429 sessions.iter().find(|s| s.id.starts_with(session_id))
1431 };
1432
1433 match matched_session {
1434 Some(info) => {
1435 let full_id = info.id.clone();
1436 let msgs = session_manager.load_session(&full_id).map_err(|e| {
1438 CliError::ConfigError(format!("Failed to load session {}: {}", full_id, e))
1439 })?;
1440 (full_id, info.clone(), msgs)
1441 }
1442 None => {
1443 let msg = Message::system(format!("❌ Session not found: {}", session_id));
1444 if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1445 chat.add_message(msg);
1446 }
1447 return Ok(());
1448 }
1449 }
1450 };
1451
1452 if let Ok(mut id_guard) = self.tui_bridge.session_id.try_lock() {
1454 *id_guard = full_session_id.clone();
1455 }
1456 if let Ok(mut input_guard) = self.tui_bridge.total_input_tokens.try_lock() {
1457 *input_guard = session_info.total_input_tokens;
1458 }
1459 if let Ok(mut output_guard) = self.tui_bridge.total_output_tokens.try_lock() {
1460 *output_guard = session_info.total_output_tokens;
1461 }
1462
1463 if let Ok(mut messages_guard) = self.tui_bridge.messages.try_lock() {
1465 *messages_guard = messages.clone();
1466 }
1467
1468 if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1470 chat.clear();
1471
1472 for msg in &messages {
1474 match msg.role {
1475 limit_llm::Role::User => {
1476 let content = msg.content.as_deref().unwrap_or("");
1477 let chat_msg = Message::user(content.to_string());
1478 chat.add_message(chat_msg);
1479 }
1480 limit_llm::Role::Assistant => {
1481 let content = msg.content.as_deref().unwrap_or("");
1482 let chat_msg = Message::assistant(content.to_string());
1483 chat.add_message(chat_msg);
1484 }
1485 _ => {}
1486 }
1487 }
1488
1489 tracing::info!(
1490 "Loaded session: {} ({} messages)",
1491 full_session_id,
1492 messages.len()
1493 );
1494
1495 let session_short_id = if full_session_id.len() > 8 {
1497 &full_session_id[full_session_id.len().saturating_sub(8)..]
1498 } else {
1499 &full_session_id
1500 };
1501 let msg = Message::system(format!(
1502 "📂 Loaded session: {} ({} messages, {} in tokens, {} out tokens)",
1503 session_short_id,
1504 messages.len(),
1505 session_info.total_input_tokens,
1506 session_info.total_output_tokens
1507 ));
1508 chat.add_message(msg);
1509 }
1510
1511 Ok(())
1512 }
1513
1514 fn prev_char_pos(&self) -> usize {
1515 if self.cursor_pos == 0 {
1516 return 0;
1517 }
1518 let mut pos = self.cursor_pos - 1;
1520 while pos > 0 && !self.input_text.is_char_boundary(pos) {
1521 pos -= 1;
1522 }
1523 pos
1524 }
1525
1526 fn next_char_pos(&self) -> usize {
1527 if self.cursor_pos >= self.input_text.len() {
1528 return self.input_text.len();
1529 }
1530 let mut pos = self.cursor_pos + 1;
1532 while pos < self.input_text.len() && !self.input_text.is_char_boundary(pos) {
1533 pos += 1;
1534 }
1535 pos
1536 }
1537
1538 fn draw(&mut self) -> Result<(), CliError> {
1539 let chat_view = self.tui_bridge.chat_view().clone();
1540 let state = self.tui_bridge.state();
1541 let input_text = self.input_text.clone();
1542 let cursor_pos = self.cursor_pos;
1543 let status_message = self.status_message.clone();
1544 let status_is_error = self.status_is_error;
1545 let cursor_blink_state = self.cursor_blink_state;
1546 let tui_bridge = &self.tui_bridge;
1547 let file_autocomplete = self.file_autocomplete.clone();
1548
1549 self.terminal
1550 .draw(|f| {
1551 Self::draw_ui(
1552 f,
1553 &chat_view,
1554 state,
1555 &input_text,
1556 cursor_pos,
1557 &status_message,
1558 status_is_error,
1559 cursor_blink_state,
1560 tui_bridge,
1561 &file_autocomplete,
1562 );
1563 })
1564 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
1565
1566 Ok(())
1567 }
1568
1569 #[allow(clippy::too_many_arguments)]
1571 fn draw_ui(
1572 f: &mut Frame,
1573 chat_view: &Arc<Mutex<ChatView>>,
1574 _state: TuiState,
1575 input_text: &str,
1576 cursor_pos: usize,
1577 status_message: &str,
1578 status_is_error: bool,
1579 cursor_blink_state: bool,
1580 tui_bridge: &TuiBridge,
1581 file_autocomplete: &Option<FileAutocompleteState>,
1582 ) {
1583 let size = f.area();
1584
1585 let activity_count = tui_bridge.activity_feed().lock().unwrap().len();
1587 let activity_height = if activity_count > 0 {
1588 (activity_count as u16).min(3) } else {
1590 0
1591 };
1592
1593 let constraints: Vec<Constraint> = vec![Constraint::Percentage(90)]; let mut constraints = constraints;
1596 if activity_height > 0 {
1597 constraints.push(Constraint::Length(activity_height)); }
1599 constraints.push(Constraint::Length(1)); constraints.push(Constraint::Length(6)); let chunks = Layout::default()
1604 .direction(Direction::Vertical)
1605 .constraints(constraints.as_slice())
1606 .split(size);
1607
1608 let mut chunk_idx = 0;
1609
1610 {
1612 let chat = chat_view.lock().unwrap();
1613 let total_input = tui_bridge.total_input_tokens();
1614 let total_output = tui_bridge.total_output_tokens();
1615 let title = format!(" Chat (↑{} ↓{}) ", total_input, total_output);
1616 let chat_block = Block::default()
1617 .borders(Borders::ALL)
1618 .title(title)
1619 .title_style(
1620 Style::default()
1621 .fg(Color::Cyan)
1622 .add_modifier(Modifier::BOLD),
1623 );
1624 f.render_widget(&*chat, chat_block.inner(chunks[chunk_idx]));
1625 f.render_widget(chat_block, chunks[chunk_idx]);
1626 chunk_idx += 1;
1627 }
1628
1629 if activity_height > 0 {
1631 let activity_feed = tui_bridge.activity_feed().lock().unwrap();
1632 let activity_block = Block::default()
1633 .borders(Borders::NONE)
1634 .style(Style::default().bg(Color::Reset));
1635 let activity_inner = activity_block.inner(chunks[chunk_idx]);
1636 f.render_widget(activity_block, chunks[chunk_idx]);
1637 activity_feed.render(activity_inner, f.buffer_mut());
1638 chunk_idx += 1;
1639 }
1640
1641 {
1643 let status_style = if status_is_error {
1644 Style::default().fg(Color::Red).bg(Color::Reset)
1645 } else {
1646 Style::default().fg(Color::Yellow)
1647 };
1648
1649 let status = Paragraph::new(Line::from(vec![
1650 Span::styled(" ● ", Style::default().fg(Color::Green)),
1651 Span::styled(status_message, status_style),
1652 ]));
1653 f.render_widget(status, chunks[chunk_idx]);
1654 chunk_idx += 1;
1655 }
1656
1657 {
1659 let input_block = Block::default()
1660 .borders(Borders::ALL)
1661 .title(" Input (Esc or /exit to quit) ")
1662 .title_style(Style::default().fg(Color::Cyan));
1663
1664 let input_inner = input_block.inner(chunks[chunk_idx]);
1665 f.render_widget(input_block, chunks[chunk_idx]);
1666
1667 let before_cursor = &input_text[..cursor_pos];
1669 let at_cursor = if cursor_pos < input_text.len() {
1670 &input_text[cursor_pos
1671 ..cursor_pos
1672 + input_text[cursor_pos..]
1673 .chars()
1674 .next()
1675 .map(|c| c.len_utf8())
1676 .unwrap_or(0)]
1677 } else {
1678 " "
1679 };
1680 let after_cursor = if cursor_pos < input_text.len() {
1681 &input_text[cursor_pos + at_cursor.len()..]
1682 } else {
1683 ""
1684 };
1685
1686 let cursor_style = if cursor_blink_state {
1687 Style::default().bg(Color::White).fg(Color::Black)
1688 } else {
1689 Style::default().bg(Color::Reset).fg(Color::Reset)
1690 };
1691
1692 let input_line = if input_text.is_empty() {
1693 Line::from(vec![Span::styled(
1694 "Type your message here...",
1695 Style::default().fg(Color::DarkGray),
1696 )])
1697 } else {
1698 Line::from(vec![
1699 Span::raw(before_cursor),
1700 Span::styled(at_cursor, cursor_style),
1701 Span::raw(after_cursor),
1702 ])
1703 };
1704
1705 let input_para = Paragraph::new(input_line).wrap(Wrap { trim: false });
1706 f.render_widget(input_para, input_inner);
1707 }
1708
1709 if let Some(ref ac) = file_autocomplete {
1711 if ac.is_active && !ac.matches.is_empty() {
1712 let input_area = chunks.last().unwrap();
1714
1715 let popup_area = calculate_popup_area(*input_area, ac.matches.len());
1717
1718 let widget = FileAutocompleteWidget::new(&ac.matches, ac.selected_index, &ac.query);
1720 f.render_widget(widget, popup_area);
1721 }
1722 }
1723 }
1724}
1725
1726#[cfg(test)]
1727mod tests {
1728 use super::*;
1729
1730 fn create_test_config() -> limit_llm::Config {
1732 use limit_llm::ProviderConfig;
1733 let mut providers = std::collections::HashMap::new();
1734 providers.insert(
1735 "anthropic".to_string(),
1736 ProviderConfig {
1737 api_key: Some("test-key".to_string()),
1738 model: "claude-3-5-sonnet-20241022".to_string(),
1739 base_url: None,
1740 max_tokens: 4096,
1741 timeout: 60,
1742 max_iterations: 100,
1743 thinking_enabled: false,
1744 clear_thinking: true,
1745 },
1746 );
1747 limit_llm::Config {
1748 provider: "anthropic".to_string(),
1749 providers,
1750 }
1751 }
1752
1753 #[test]
1754 fn test_tui_bridge_new() {
1755 let config = create_test_config();
1756 let agent_bridge = AgentBridge::new(config).unwrap();
1757 let (_tx, rx) = mpsc::unbounded_channel();
1758
1759 let tui_bridge = TuiBridge::new(agent_bridge, rx).unwrap();
1760 assert_eq!(tui_bridge.state(), TuiState::Idle);
1761 }
1762
1763 #[test]
1764 fn test_tui_bridge_state() {
1765 let config = create_test_config();
1766 let agent_bridge = AgentBridge::new(config).unwrap();
1767 let (tx, rx) = mpsc::unbounded_channel();
1768
1769 let mut tui_bridge = TuiBridge::new(agent_bridge, rx).unwrap();
1770
1771 tx.send(AgentEvent::Thinking).unwrap();
1772 tui_bridge.process_events().unwrap();
1773 assert!(matches!(tui_bridge.state(), TuiState::Thinking));
1774
1775 tx.send(AgentEvent::Done).unwrap();
1776 tui_bridge.process_events().unwrap();
1777 assert_eq!(tui_bridge.state(), TuiState::Idle);
1778 }
1779
1780 #[test]
1781 fn test_tui_bridge_chat_view() {
1782 let config = create_test_config();
1783 let agent_bridge = AgentBridge::new(config).unwrap();
1784 let (_tx, rx) = mpsc::unbounded_channel();
1785
1786 let tui_bridge = TuiBridge::new(agent_bridge, rx).unwrap();
1787
1788 tui_bridge.add_user_message("Hello".to_string());
1789 assert_eq!(tui_bridge.chat_view().lock().unwrap().message_count(), 3); }
1791
1792 #[test]
1793 fn test_tui_state_default() {
1794 let state = TuiState::default();
1795 assert_eq!(state, TuiState::Idle);
1796 }
1797}