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 let mut event_count = 0;
206 while let Ok(event) = self.event_rx.try_recv() {
207 event_count += 1;
208 match event {
209 AgentEvent::Thinking => {
210 debug_log("process_events: Thinking event received");
211 *self.state.lock().unwrap() = TuiState::Thinking;
212 }
213 AgentEvent::ToolStart { name, args } => {
214 debug_log(&format!("process_events: ToolStart event - {}", name));
215 let activity_msg = Self::format_activity_message(&name, &args);
216 self.activity_feed.lock().unwrap().add(activity_msg, true);
218 }
219 AgentEvent::ToolComplete { name: _, result: _ } => {
220 debug_log("process_events: ToolComplete event");
221 self.activity_feed.lock().unwrap().complete_current();
223 }
224 AgentEvent::ContentChunk(chunk) => {
225 debug_log(&format!(
226 "process_events: ContentChunk event ({} chars)",
227 chunk.len()
228 ));
229 self.chat_view
230 .lock()
231 .unwrap()
232 .append_to_last_assistant(&chunk);
233 }
234 AgentEvent::Done => {
235 debug_log("process_events: Done event received");
236 *self.state.lock().unwrap() = TuiState::Idle;
237 self.activity_feed.lock().unwrap().complete_all();
239 }
240 AgentEvent::Error(err) => {
241 debug_log(&format!("process_events: Error event - {}", err));
242 *self.state.lock().unwrap() = TuiState::Idle;
244 let chat_msg = Message::system(format!("Error: {}", err));
245 self.chat_view.lock().unwrap().add_message(chat_msg);
246 }
247 AgentEvent::TokenUsage {
248 input_tokens,
249 output_tokens,
250 } => {
251 debug_log(&format!(
252 "process_events: TokenUsage event - in={}, out={}",
253 input_tokens, output_tokens
254 ));
255 *self.total_input_tokens.lock().unwrap() += input_tokens;
257 *self.total_output_tokens.lock().unwrap() += output_tokens;
258 }
259 }
260 }
261 if event_count > 0 {
262 debug_log(&format!("process_events: processed {} events", event_count));
263 }
264 Ok(())
265 }
266
267 fn format_activity_message(tool_name: &str, args: &serde_json::Value) -> String {
268 match tool_name {
269 "file_read" => args
270 .get("path")
271 .and_then(|p| p.as_str())
272 .map(|p| format!("Reading {}...", Self::truncate_path(p, 40)))
273 .unwrap_or_else(|| "Reading file...".to_string()),
274 "file_write" => args
275 .get("path")
276 .and_then(|p| p.as_str())
277 .map(|p| format!("Writing {}...", Self::truncate_path(p, 40)))
278 .unwrap_or_else(|| "Writing file...".to_string()),
279 "file_edit" => args
280 .get("path")
281 .and_then(|p| p.as_str())
282 .map(|p| format!("Editing {}...", Self::truncate_path(p, 40)))
283 .unwrap_or_else(|| "Editing file...".to_string()),
284 "bash" => args
285 .get("command")
286 .and_then(|c| c.as_str())
287 .map(|c| format!("Running {}...", Self::truncate_command(c, 30)))
288 .unwrap_or_else(|| "Executing command...".to_string()),
289 "git_status" => "Checking git status...".to_string(),
290 "git_diff" => "Checking git diff...".to_string(),
291 "git_log" => "Checking git log...".to_string(),
292 "git_add" => "Staging files...".to_string(),
293 "git_commit" => "Creating commit...".to_string(),
294 "git_push" => "Pushing to remote...".to_string(),
295 "git_pull" => "Pulling from remote...".to_string(),
296 "git_clone" => args
297 .get("url")
298 .and_then(|u| u.as_str())
299 .map(|u| format!("Cloning {}...", Self::truncate_path(u, 40)))
300 .unwrap_or_else(|| "Cloning repository...".to_string()),
301 "grep" => args
302 .get("pattern")
303 .and_then(|p| p.as_str())
304 .map(|p| format!("Searching for '{}'...", Self::truncate_command(p, 30)))
305 .unwrap_or_else(|| "Searching...".to_string()),
306 "ast_grep" => args
307 .get("pattern")
308 .and_then(|p| p.as_str())
309 .map(|p| format!("AST searching '{}'...", Self::truncate_command(p, 25)))
310 .unwrap_or_else(|| "AST searching...".to_string()),
311 "lsp" => args
312 .get("command")
313 .and_then(|c| c.as_str())
314 .map(|c| format!("Running LSP {}...", c))
315 .unwrap_or_else(|| "Running LSP...".to_string()),
316 _ => format!("Executing {}...", tool_name),
317 }
318 }
319
320 fn truncate_path(s: &str, max_len: usize) -> String {
321 if s.len() <= max_len {
322 s.to_string()
323 } else {
324 format!("...{}", &s[s.len().saturating_sub(max_len - 3)..])
325 }
326 }
327
328 fn truncate_command(s: &str, max_len: usize) -> String {
329 if s.len() <= max_len {
330 s.to_string()
331 } else {
332 format!("{}...", &s[..max_len.saturating_sub(3)])
333 }
334 }
335
336 pub fn add_user_message(&self, content: String) {
338 let msg = Message::user(content);
339 self.chat_view.lock().unwrap().add_message(msg);
340 }
341
342 pub fn tick_spinner(&self) {
344 self.spinner.lock().unwrap().tick();
345 }
346
347 pub fn is_busy(&self) -> bool {
349 !matches!(self.state(), TuiState::Idle)
350 }
351
352 pub fn total_input_tokens(&self) -> u64 {
354 self.total_input_tokens
355 .lock()
356 .map(|guard| *guard)
357 .unwrap_or(0)
358 }
359
360 pub fn total_output_tokens(&self) -> u64 {
362 self.total_output_tokens
363 .lock()
364 .map(|guard| *guard)
365 .unwrap_or(0)
366 }
367
368 pub fn session_id(&self) -> String {
370 self.session_id
371 .lock()
372 .map(|guard| guard.clone())
373 .unwrap_or_else(|_| String::from("unknown"))
374 }
375
376 pub fn save_session(&self) -> Result<(), CliError> {
378 let session_id = self
379 .session_id
380 .lock()
381 .map(|guard| guard.clone())
382 .unwrap_or_else(|_| String::from("unknown"));
383
384 let messages = self
385 .messages
386 .lock()
387 .map(|guard| guard.clone())
388 .unwrap_or_default();
389
390 let input_tokens = self
391 .total_input_tokens
392 .lock()
393 .map(|guard| *guard)
394 .unwrap_or(0);
395
396 let output_tokens = self
397 .total_output_tokens
398 .lock()
399 .map(|guard| *guard)
400 .unwrap_or(0);
401
402 tracing::debug!(
403 "Saving session {} with {} messages, {} in tokens, {} out tokens",
404 session_id,
405 messages.len(),
406 input_tokens,
407 output_tokens
408 );
409
410 let session_manager = self.session_manager.lock().map_err(|e| {
411 CliError::ConfigError(format!("Failed to acquire session manager lock: {}", e))
412 })?;
413
414 session_manager.save_session(&session_id, &messages, input_tokens, output_tokens)?;
415 tracing::info!(
416 "✓ Session {} saved successfully ({} messages, {} in tokens, {} out tokens)",
417 session_id,
418 messages.len(),
419 input_tokens,
420 output_tokens
421 );
422 Ok(())
423 }
424}
425
426pub struct TuiApp {
428 tui_bridge: TuiBridge,
429 terminal: Terminal<CrosstermBackend<io::Stdout>>,
430 running: bool,
431 input_text: String,
432 cursor_pos: usize,
433 status_message: String,
434 status_is_error: bool,
435 cursor_blink_state: bool,
436 cursor_blink_timer: std::time::Instant,
437 mouse_selection_start: Option<(u16, u16)>,
439 clipboard: Option<ClipboardManager>,
441 file_autocomplete: Option<FileAutocompleteState>,
443 file_finder: FileFinder,
445}
446
447impl TuiApp {
448 pub fn new(tui_bridge: TuiBridge) -> Result<Self, CliError> {
450 let backend = CrosstermBackend::new(io::stdout());
451 let terminal =
452 Terminal::new(backend).map_err(|e| CliError::IoError(io::Error::other(e)))?;
453
454 let session_id = tui_bridge.session_id();
455 tracing::info!("TUI started with session: {}", session_id);
456
457 let clipboard = match ClipboardManager::new() {
458 Ok(cb) => {
459 debug_log("✓ Clipboard initialized successfully");
460 tracing::info!("Clipboard initialized successfully");
461 Some(cb)
462 }
463 Err(e) => {
464 debug_log(&format!("✗ Clipboard initialization failed: {}", e));
465 tracing::warn!("Clipboard unavailable: {}", e);
466 None
467 }
468 };
469
470 let working_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
472 let file_finder = FileFinder::new(working_dir);
473
474 Ok(Self {
475 tui_bridge,
476 terminal,
477 running: true,
478 input_text: String::new(),
479 cursor_pos: 0,
480 status_message: "Ready - Type a message and press Enter".to_string(),
481 status_is_error: false,
482 cursor_blink_state: true,
483 cursor_blink_timer: std::time::Instant::now(),
484 mouse_selection_start: None,
485 clipboard,
486 file_autocomplete: None,
487 file_finder,
488 })
489 }
490
491 pub fn run(&mut self) -> Result<(), CliError> {
493 execute!(std::io::stdout(), EnterAlternateScreen)
495 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
496
497 execute!(std::io::stdout(), EnableMouseCapture)
499 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
500
501 execute!(std::io::stdout(), EnableBracketedPaste)
503 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
504
505 crossterm::terminal::enable_raw_mode()
506 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
507
508 struct AlternateScreenGuard;
510 impl Drop for AlternateScreenGuard {
511 fn drop(&mut self) {
512 let _ = crossterm::terminal::disable_raw_mode();
513 let _ = execute!(std::io::stdout(), DisableBracketedPaste);
514 let _ = execute!(std::io::stdout(), DisableMouseCapture);
515 let _ = execute!(std::io::stdout(), LeaveAlternateScreen);
516 }
517 }
518 let _guard = AlternateScreenGuard;
519
520 self.run_inner()
521 }
522
523 fn run_inner(&mut self) -> Result<(), CliError> {
524 while self.running {
525 self.tui_bridge.process_events()?;
527
528 if matches!(self.tui_bridge.state(), TuiState::Thinking) {
530 self.tui_bridge.tick_spinner();
531 }
532
533 self.update_status();
535
536 if crossterm::event::poll(std::time::Duration::from_millis(100))
538 .map_err(|e| CliError::IoError(io::Error::other(e)))?
539 {
540 match event::read().map_err(|e| CliError::IoError(io::Error::other(e)))? {
541 Event::Key(key) => {
542 debug_log(&format!(
544 "Event::Key - code={:?} mod={:?} kind={:?}",
545 key.code, key.modifiers, key.kind
546 ));
547
548 if key.kind == KeyEventKind::Press {
549 self.handle_key_event(key)?;
550 }
551 }
552 Event::Mouse(mouse) => {
553 match mouse.kind {
554 MouseEventKind::Down(MouseButton::Left) => {
555 debug_log(&format!(
556 "MouseDown at ({}, {})",
557 mouse.column, mouse.row
558 ));
559 self.mouse_selection_start = Some((mouse.column, mouse.row));
560 let chat = self.tui_bridge.chat_view().lock().unwrap();
562 debug_log(&format!(
563 " render_positions count: {}",
564 chat.render_position_count()
565 ));
566 if let Some((msg_idx, char_offset)) =
567 chat.screen_to_text_pos(mouse.column, mouse.row)
568 {
569 debug_log(&format!(
570 " -> Starting selection at msg={}, offset={}",
571 msg_idx, char_offset
572 ));
573 drop(chat);
574 self.tui_bridge
575 .chat_view()
576 .lock()
577 .unwrap()
578 .start_selection(msg_idx, char_offset);
579 } else {
580 debug_log(" -> No match, clearing selection");
581 drop(chat);
582 self.tui_bridge
583 .chat_view()
584 .lock()
585 .unwrap()
586 .clear_selection();
587 }
588 }
589 MouseEventKind::Drag(MouseButton::Left) => {
590 if self.mouse_selection_start.is_some() {
591 let chat = self.tui_bridge.chat_view().lock().unwrap();
593 if let Some((msg_idx, char_offset)) =
594 chat.screen_to_text_pos(mouse.column, mouse.row)
595 {
596 drop(chat);
597 self.tui_bridge
598 .chat_view()
599 .lock()
600 .unwrap()
601 .extend_selection(msg_idx, char_offset);
602 }
603 }
604 }
605 MouseEventKind::Up(MouseButton::Left) => {
606 self.mouse_selection_start = None;
607 }
608 MouseEventKind::ScrollUp => {
609 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
610 chat.scroll_up();
611 }
612 MouseEventKind::ScrollDown => {
613 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
614 chat.scroll_down();
615 }
616 _ => {}
617 }
618 }
619 Event::Paste(pasted) => {
620 if !self.tui_bridge.is_busy() {
621 self.insert_paste(&pasted);
622 }
623 }
624 _ => {}
625 }
626 } else {
627 self.tick_cursor_blink();
629 }
630
631 self.draw()?;
633 }
634
635 if let Err(e) = self.tui_bridge.save_session() {
637 tracing::error!("Failed to save session: {}", e);
638 }
639
640 Ok(())
641 }
642
643 fn update_status(&mut self) {
644 let session_id = self.tui_bridge.session_id();
645 let has_activity = self
646 .tui_bridge
647 .activity_feed()
648 .lock()
649 .unwrap()
650 .has_in_progress();
651
652 match self.tui_bridge.state() {
653 TuiState::Idle => {
654 if has_activity {
655 let spinner = self.tui_bridge.spinner().lock().unwrap();
657 self.status_message = format!("{} Processing...", spinner.current_frame());
658 } else {
659 self.status_message = format!(
660 "Ready | Session: {}",
661 session_id.chars().take(8).collect::<String>()
662 );
663 }
664 self.status_is_error = false;
665 }
666 TuiState::Thinking => {
667 let spinner = self.tui_bridge.spinner().lock().unwrap();
668 self.status_message = format!("{} Thinking...", spinner.current_frame());
669 self.status_is_error = false;
670 }
671 }
672 }
673
674 fn insert_paste(&mut self, text: &str) {
676 let text = if text.len() > MAX_PASTE_SIZE {
678 self.status_message = "Paste truncated (too large)".to_string();
679 self.status_is_error = true;
680 &text[..text
682 .char_indices()
683 .nth(MAX_PASTE_SIZE)
684 .map(|(i, _)| i)
685 .unwrap_or(text.len())]
686 } else {
687 text
688 };
689
690 let normalized = text.replace("\r", "\n");
692 self.input_text.insert_str(self.cursor_pos, &normalized);
693 self.cursor_pos += normalized.len();
694 }
695
696 fn is_copy_paste_modifier(&self, key: &KeyEvent, char: char) -> bool {
700 #[cfg(target_os = "macos")]
701 {
702 let has_super = key.modifiers.contains(KeyModifiers::SUPER);
704 let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
705 let result = key.code == KeyCode::Char(char) && (has_super || has_ctrl);
706 debug_log(&format!("is_copy_paste_modifier('{}') macOS: code={:?}, mod={:?}, super={}, ctrl={}, result={}",
707 char, key.code, key.modifiers, has_super, has_ctrl, result));
708 result
709 }
710 #[cfg(not(target_os = "macos"))]
711 {
712 let result =
713 key.code == KeyCode::Char(char) && key.modifiers.contains(KeyModifiers::CONTROL);
714 debug_log(&format!(
715 "is_copy_paste_modifier('{}') non-macOS: code={:?}, mod={:?}, ctrl={:?}, result={}",
716 char,
717 key.code,
718 key.modifiers,
719 KeyModifiers::CONTROL,
720 result
721 ));
722 result
723 }
724 }
725
726 fn tick_cursor_blink(&mut self) {
727 if self.cursor_blink_timer.elapsed().as_millis() > 500 {
729 self.cursor_blink_state = !self.cursor_blink_state;
730 self.cursor_blink_timer = std::time::Instant::now();
731 }
732 }
733
734 fn handle_key_event(&mut self, key: KeyEvent) -> Result<(), CliError> {
735 debug_log(&format!(
737 "handle_key_event: code={:?} mod={:?} kind={:?}",
738 key.code, key.modifiers, key.kind
739 ));
740
741 if matches!(key.code, KeyCode::Char('c') | KeyCode::Char('v')) {
743 debug_log(&format!(
744 ">>> SPECIAL: '{}' key detected with modifiers: {:?} (SUPER={:?}, CONTROL={:?})",
745 if matches!(key.code, KeyCode::Char('c')) {
746 'c'
747 } else {
748 'v'
749 },
750 key.modifiers,
751 key.modifiers.contains(KeyModifiers::SUPER),
752 key.modifiers.contains(KeyModifiers::CONTROL)
753 ));
754 }
755
756 if self.is_copy_paste_modifier(&key, 'c') {
758 debug_log("✓ Copy shortcut CONFIRMED - processing...");
759 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
760 let has_selection = chat.has_selection();
761 debug_log(&format!("has_selection={}", has_selection));
762
763 if has_selection {
764 if let Some(selected) = chat.get_selected_text() {
765 debug_log(&format!("Selected text length={}", selected.len()));
766 if !selected.is_empty() {
767 if let Some(ref clipboard) = self.clipboard {
768 debug_log("Attempting to copy to clipboard...");
769 match clipboard.set_text(&selected) {
770 Ok(()) => {
771 debug_log("✓ Clipboard copy successful");
772 self.status_message = "Copied to clipboard".to_string();
773 self.status_is_error = false;
774 }
775 Err(e) => {
776 debug_log(&format!("✗ Clipboard copy failed: {}", e));
777 self.status_message = format!("Clipboard error: {}", e);
778 self.status_is_error = true;
779 }
780 }
781 } else {
782 debug_log("✗ Clipboard not available (None)");
783 self.status_message = "Clipboard not available".to_string();
784 self.status_is_error = true;
785 }
786 } else {
787 debug_log("Selected text is empty");
788 }
789 chat.clear_selection();
790 } else {
791 debug_log("get_selected_text() returned None");
792 }
793 return Ok(());
794 }
795
796 debug_log("Ctrl/Cmd+C with no selection - ignoring");
798 return Ok(());
799 }
800
801 if self.is_copy_paste_modifier(&key, 'v') && !self.tui_bridge.is_busy() {
803 debug_log("✓ Paste shortcut CONFIRMED - processing...");
804 if let Some(ref clipboard) = self.clipboard {
805 debug_log("Attempting to read from clipboard...");
806 match clipboard.get_text() {
807 Ok(text) if !text.is_empty() => {
808 debug_log(&format!("Read {} chars from clipboard", text.len()));
809 self.insert_paste(&text);
810 }
811 Ok(_) => {
812 debug_log("Clipboard is empty");
813 } Err(e) => {
815 debug_log(&format!("✗ Failed to read clipboard: {}", e));
816 self.status_message = format!("Could not read clipboard: {}", e);
817 self.status_is_error = true;
818 }
819 }
820 } else {
821 debug_log("✗ Clipboard not available (None)");
822 self.status_message = "Clipboard not available".to_string();
823 self.status_is_error = true;
824 }
825 return Ok(());
826 }
827
828 if let Some(ref mut ac) = self.file_autocomplete {
830 if ac.is_active {
831 match key.code {
832 KeyCode::Up => {
833 if ac.selected_index > 0 {
834 ac.selected_index -= 1;
835 }
836 return Ok(());
837 }
838 KeyCode::Down => {
839 if ac.selected_index + 1 < ac.matches.len() {
840 ac.selected_index += 1;
841 }
842 return Ok(());
843 }
844 KeyCode::Enter | KeyCode::Tab => {
845 self.accept_file_completion();
847 return Ok(());
848 }
849 KeyCode::Esc => {
850 self.file_autocomplete = None;
852 return Ok(());
853 }
854 _ => {}
855 }
856 }
857 }
858
859 let term_height = self.terminal.size().map(|s| s.height).unwrap_or(24);
862 let viewport_height = term_height
863 .saturating_sub(1) .saturating_sub(7); match key.code {
866 KeyCode::PageUp => {
867 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
868 chat.scroll_page_up(viewport_height);
869 return Ok(());
870 }
871 KeyCode::PageDown => {
872 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
873 chat.scroll_page_down(viewport_height);
874 return Ok(());
875 }
876 KeyCode::Up => {
877 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
878 chat.scroll_up();
879 return Ok(());
880 }
881 KeyCode::Down => {
882 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
884 chat.scroll_down();
885 return Ok(());
886 }
887 _ => {}
888 }
889
890 if self.tui_bridge.is_busy() {
892 debug_log("Agent busy, ignoring");
893 return Ok(());
894 }
895
896 if self.handle_backspace(&key) {
898 debug_log(&format!("Backspace handled, input: {:?}", self.input_text));
899 return Ok(());
900 }
901
902 match key.code {
903 KeyCode::Delete => {
904 if self.cursor_pos < self.input_text.len() {
905 let next_pos = self.next_char_pos();
906 self.input_text.drain(self.cursor_pos..next_pos);
907 debug_log(&format!("Delete: input now: {:?}", self.input_text));
908 }
909 }
910 KeyCode::Left => {
911 if self.cursor_pos > 0 {
912 self.cursor_pos = self.prev_char_pos();
913 }
914 }
915 KeyCode::Right => {
916 if self.cursor_pos < self.input_text.len() {
917 self.cursor_pos = self.next_char_pos();
918 }
919 }
920 KeyCode::Home => {
921 self.cursor_pos = 0;
922 }
923 KeyCode::End => {
924 self.cursor_pos = self.input_text.len();
925 }
926 KeyCode::Enter => {
927 self.handle_enter()?;
929 }
930 KeyCode::Esc => {
931 if self.file_autocomplete.is_some() {
933 self.file_autocomplete = None;
934 } else {
935 debug_log("Esc pressed, exiting");
936 self.running = false;
937 }
938 }
939 KeyCode::Char(c)
941 if key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT =>
942 {
943 if c == '@' {
945 self.input_text.insert(self.cursor_pos, '@');
947 self.cursor_pos += 1;
948
949 self.activate_file_autocomplete();
951 } else {
952 let is_autocomplete_active = self
954 .file_autocomplete
955 .as_ref()
956 .map(|ac| ac.is_active)
957 .unwrap_or(false);
958
959 if is_autocomplete_active {
960 let query = self
962 .file_autocomplete
963 .as_ref()
964 .map(|ac| {
965 let mut q = ac.query.clone();
966 q.push(c);
967 q
968 })
969 .unwrap_or_default();
970
971 let matches = self.get_file_matches(&query);
973
974 if let Some(ref mut ac) = self.file_autocomplete {
976 ac.query.push(c);
977 ac.matches = matches;
978 ac.selected_index = 0;
979 }
980
981 self.input_text.insert(self.cursor_pos, c);
983 self.cursor_pos += c.len_utf8();
984 } else {
985 self.input_text.insert(self.cursor_pos, c);
987 self.cursor_pos += c.len_utf8();
988 }
989 }
990 }
991 _ => {
992 }
994 }
995
996 Ok(())
997 }
998
999 fn handle_backspace(&mut self, key: &KeyEvent) -> bool {
1001 if key.code == KeyCode::Backspace {
1003 debug_log("Backspace detected via KeyCode::Backspace");
1004 self.delete_char_before_cursor();
1005 return true;
1006 }
1007
1008 if key.code == KeyCode::Char('h') && key.modifiers == KeyModifiers::CONTROL {
1010 debug_log("Backspace detected via Ctrl+H");
1011 self.delete_char_before_cursor();
1012 return true;
1013 }
1014
1015 if let KeyCode::Char(c) = key.code {
1017 if c == '\x7f' || c == '\x08' {
1018 debug_log(&format!("Backspace detected via char code: {}", c as u8));
1019 self.delete_char_before_cursor();
1020 return true;
1021 }
1022 }
1023
1024 false
1025 }
1026
1027 fn delete_char_before_cursor(&mut self) {
1028 debug_log(&format!(
1029 "delete_char: cursor={}, len={}, input={:?}",
1030 self.cursor_pos,
1031 self.input_text.len(),
1032 self.input_text
1033 ));
1034
1035 let should_close_autocomplete = if let Some(ref ac) = self.file_autocomplete {
1037 if ac.is_active {
1038 ac.query.is_empty()
1040 } else {
1041 false
1042 }
1043 } else {
1044 false
1045 };
1046
1047 if should_close_autocomplete {
1048 if self.cursor_pos > 0 {
1050 let prev_pos = self.prev_char_pos();
1051 if &self.input_text[prev_pos..self.cursor_pos] == "@" {
1052 self.input_text.drain(prev_pos..self.cursor_pos);
1053 self.cursor_pos = prev_pos;
1054 self.file_autocomplete = None;
1055 return;
1056 }
1057 }
1058 }
1059
1060 if self
1062 .file_autocomplete
1063 .as_ref()
1064 .map(|ac| ac.is_active && !ac.query.is_empty())
1065 .unwrap_or(false)
1066 {
1067 let new_query = self
1068 .file_autocomplete
1069 .as_ref()
1070 .map(|ac| {
1071 let mut q = ac.query.clone();
1072 q.pop();
1073 q
1074 })
1075 .unwrap_or_default();
1076
1077 let matches = self.get_file_matches(&new_query);
1078
1079 if let Some(ref mut ac) = self.file_autocomplete {
1080 ac.query.pop();
1081 ac.matches = matches;
1082 ac.selected_index = 0;
1083 }
1084 }
1085
1086 if self.cursor_pos > 0 {
1087 let prev_pos = self.prev_char_pos();
1088 debug_log(&format!("draining {}..{}", prev_pos, self.cursor_pos));
1089 self.input_text.drain(prev_pos..self.cursor_pos);
1090 self.cursor_pos = prev_pos;
1091 debug_log(&format!(
1092 "after delete: cursor={}, input={:?}",
1093 self.cursor_pos, self.input_text
1094 ));
1095 } else {
1096 debug_log("cursor at 0, nothing to delete");
1097 }
1098 }
1099
1100 fn activate_file_autocomplete(&mut self) {
1102 let matches = self.get_file_matches("");
1103
1104 self.file_autocomplete = Some(FileAutocompleteState {
1105 is_active: true,
1106 query: String::new(),
1107 trigger_pos: self.cursor_pos - 1, matches,
1109 selected_index: 0,
1110 });
1111
1112 debug_log(&format!(
1113 "Activated autocomplete at pos {}",
1114 self.cursor_pos - 1
1115 ));
1116 }
1117
1118 fn get_file_matches(&mut self, query: &str) -> Vec<FileMatchData> {
1120 let files = self.file_finder.scan_files().clone();
1121 let matches = self.file_finder.filter_files(&files, query);
1122
1123 matches
1124 .into_iter()
1125 .map(|m| FileMatchData {
1126 path: m.path.to_string_lossy().to_string(),
1127 is_dir: m.is_dir,
1128 })
1129 .collect()
1130 }
1131
1132 fn accept_file_completion(&mut self) {
1134 if let Some(ref ac) = self.file_autocomplete {
1135 if let Some(selected) = ac.matches.get(ac.selected_index) {
1136 let end_pos = self.cursor_pos;
1138
1139 let remove_start = ac.trigger_pos;
1141
1142 self.input_text.drain(remove_start + 1..end_pos);
1144 self.cursor_pos = remove_start + 1;
1145
1146 self.input_text.insert_str(self.cursor_pos, &selected.path);
1148 self.cursor_pos += selected.path.len();
1149
1150 self.input_text.insert(self.cursor_pos, ' ');
1152 self.cursor_pos += 1;
1153
1154 debug_log(&format!(
1155 "Accepted completion: {} -> input now: {:?}",
1156 selected.path, self.input_text
1157 ));
1158 }
1159 }
1160
1161 self.file_autocomplete = None;
1163 }
1164
1165 fn handle_enter(&mut self) -> Result<(), CliError> {
1166 let text = self.input_text.trim().to_string();
1167
1168 self.input_text.clear();
1170 self.cursor_pos = 0;
1171
1172 if text.is_empty() {
1173 return Ok(());
1174 }
1175
1176 tracing::info!("Enter pressed with text: {:?}", text);
1177
1178 let text_lower = text.to_lowercase();
1180 if text_lower == "/exit"
1181 || text_lower == "/quit"
1182 || text_lower == "exit"
1183 || text_lower == "quit"
1184 {
1185 tracing::info!("Exit command detected, exiting");
1186 self.running = false;
1187 return Ok(());
1188 }
1189
1190 if text_lower == "/clear" || text_lower == "clear" {
1191 tracing::info!("Clear command detected");
1192 self.tui_bridge.chat_view().lock().unwrap().clear();
1193 return Ok(());
1194 }
1195
1196 if text_lower == "/help" || text_lower == "help" {
1197 tracing::info!("Help command detected");
1198 let help_msg = Message::system(
1199 "Available commands:\n\
1200 /help - Show this help message\n\
1201 /clear - Clear chat history\n\
1202 /exit - Exit the application\n\
1203 /quit - Exit the application\n\
1204 /session list - List all sessions\n\
1205 /session new - Create a new session\n\
1206 /session load <id> - Load a session by ID\n\
1207 /share - Copy session to clipboard (markdown)\n\
1208 /share md - Export session as markdown file\n\
1209 /share json - Export session as JSON file\n\
1210 \n\
1211 Page Up/Down - Scroll chat history"
1212 .to_string(),
1213 );
1214 self.tui_bridge
1215 .chat_view()
1216 .lock()
1217 .unwrap()
1218 .add_message(help_msg);
1219 return Ok(());
1220 }
1221
1222 if text_lower.starts_with("/session ") {
1224 let session_cmd = text.strip_prefix("/session ").unwrap();
1225 if session_cmd.trim() == "list" {
1226 self.handle_session_list()?;
1227 return Ok(());
1228 } else if session_cmd.trim() == "new" {
1229 self.handle_session_new()?;
1230 return Ok(());
1231 } else if session_cmd.starts_with("load ") {
1232 let session_id = session_cmd.strip_prefix("load ").unwrap().trim();
1233 self.handle_session_load(session_id)?;
1234 return Ok(());
1235 } else {
1236 let error_msg = Message::system(
1237 "Usage: /session list, /session new, /session load <id>".to_string(),
1238 );
1239 self.tui_bridge
1240 .chat_view()
1241 .lock()
1242 .unwrap()
1243 .add_message(error_msg);
1244 return Ok(());
1245 }
1246 }
1247
1248 if text_lower == "/share" || text_lower.starts_with("/share ") {
1250 let share_cmd = text.strip_prefix("/share ").unwrap_or("").trim();
1251 self.handle_share(share_cmd)?;
1252 return Ok(());
1253 }
1254
1255 self.tui_bridge.add_user_message(text.clone());
1257
1258 let messages = self.tui_bridge.messages.clone();
1260 let agent_bridge = self.tui_bridge.agent_bridge_arc();
1261 let session_manager = self.tui_bridge.session_manager.clone();
1262 let session_id = self.tui_bridge.session_id();
1263 let total_input_tokens = self.tui_bridge.total_input_tokens.clone();
1264 let total_output_tokens = self.tui_bridge.total_output_tokens.clone();
1265
1266 tracing::debug!("Spawning LLM processing thread");
1267
1268 std::thread::spawn(move || {
1270 let rt = tokio::runtime::Runtime::new().unwrap();
1272
1273 #[allow(clippy::await_holding_lock)]
1275 rt.block_on(async {
1276 let mut messages_guard = messages.lock().unwrap();
1277 let mut bridge = agent_bridge.lock().unwrap();
1278
1279 match bridge.process_message(&text, &mut messages_guard).await {
1280 Ok(_response) => {
1281 let msgs = messages_guard.clone();
1287 let input_tokens = *total_input_tokens.lock().unwrap();
1288 let output_tokens = *total_output_tokens.lock().unwrap();
1289
1290 if let Err(e) = session_manager.lock().unwrap().save_session(
1291 &session_id,
1292 &msgs,
1293 input_tokens,
1294 output_tokens,
1295 ) {
1296 tracing::error!("✗ Failed to auto-save session {}: {}", session_id, e);
1297 } else {
1298 tracing::info!(
1299 "✓ Session {} auto-saved ({} messages, {} in, {} out tokens)",
1300 session_id,
1301 msgs.len(),
1302 input_tokens,
1303 output_tokens
1304 );
1305 }
1306 }
1307 Err(e) => {
1308 tracing::error!("LLM error: {}", e);
1309 }
1310 }
1311 });
1312 });
1313
1314 Ok(())
1315 }
1316
1317 fn handle_session_list(&self) -> Result<(), CliError> {
1319 tracing::info!("Session list command detected");
1320 let session_manager = self.tui_bridge.session_manager.lock().unwrap();
1321 let current_session_id = self.tui_bridge.session_id();
1322
1323 match session_manager.list_sessions() {
1324 Ok(sessions) => {
1325 if sessions.is_empty() {
1326 let msg = Message::system("No sessions found.".to_string());
1327 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1328 } else {
1329 let mut output = vec!["Sessions (most recent first):".to_string()];
1330 for (i, session) in sessions.iter().enumerate() {
1331 let current = if session.id == current_session_id {
1332 " (current)"
1333 } else {
1334 ""
1335 };
1336 let short_id = if session.id.len() > 8 {
1337 &session.id[..8]
1338 } else {
1339 &session.id
1340 };
1341 output.push(format!(
1342 " {}. {}{} - {} messages, {} in tokens, {} out tokens",
1343 i + 1,
1344 short_id,
1345 current,
1346 session.message_count,
1347 session.total_input_tokens,
1348 session.total_output_tokens
1349 ));
1350 }
1351 let msg = Message::system(output.join("\n"));
1352 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1353 }
1354 }
1355 Err(e) => {
1356 let msg = Message::system(format!("Error listing sessions: {}", e));
1357 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1358 }
1359 }
1360 Ok(())
1361 }
1362
1363 fn handle_session_new(&mut self) -> Result<(), CliError> {
1365 tracing::info!("Session new command detected");
1366
1367 let save_result = self.tui_bridge.save_session();
1369 if let Err(e) = &save_result {
1370 tracing::error!("Failed to save current session: {}", e);
1371 let msg = Message::system(format!("⚠ Warning: Failed to save current session: {}", e));
1372 if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1373 chat.add_message(msg);
1374 }
1375 }
1376
1377 let new_session_id = {
1379 let session_manager = self.tui_bridge.session_manager.lock().map_err(|e| {
1380 CliError::ConfigError(format!("Failed to acquire session manager lock: {}", e))
1381 })?;
1382
1383 session_manager
1384 .create_new_session()
1385 .map_err(|e| CliError::ConfigError(format!("Failed to create session: {}", e)))?
1386 };
1387
1388 let old_session_id = self.tui_bridge.session_id();
1389
1390 if let Ok(mut id_guard) = self.tui_bridge.session_id.try_lock() {
1392 *id_guard = new_session_id.clone();
1393 }
1394
1395 if let Ok(mut messages_guard) = self.tui_bridge.messages.try_lock() {
1397 messages_guard.clear();
1398 }
1399 if let Ok(mut input_guard) = self.tui_bridge.total_input_tokens.try_lock() {
1400 *input_guard = 0;
1401 }
1402 if let Ok(mut output_guard) = self.tui_bridge.total_output_tokens.try_lock() {
1403 *output_guard = 0;
1404 }
1405
1406 if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1408 chat.clear();
1409 }
1410
1411 tracing::info!(
1412 "Created new session: {} (old: {})",
1413 new_session_id,
1414 old_session_id
1415 );
1416
1417 let session_short_id = if new_session_id.len() > 8 {
1419 &new_session_id[new_session_id.len().saturating_sub(8)..]
1420 } else {
1421 &new_session_id
1422 };
1423 let msg = Message::system(format!("🆕 New session created: {}", session_short_id));
1424
1425 if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1426 chat.add_message(msg);
1427 }
1428
1429 Ok(())
1430 }
1431
1432 fn handle_session_load(&mut self, session_id: &str) -> Result<(), CliError> {
1434 tracing::info!("Session load command detected for session: {}", session_id);
1435
1436 let save_result = self.tui_bridge.save_session();
1438 if let Err(e) = &save_result {
1439 tracing::error!("Failed to save current session: {}", e);
1440 let msg = Message::system(format!("⚠ Warning: Failed to save current session: {}", e));
1441 if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1442 chat.add_message(msg);
1443 }
1444 }
1445
1446 let (full_session_id, session_info, messages) = {
1448 let session_manager = self.tui_bridge.session_manager.lock().map_err(|e| {
1449 CliError::ConfigError(format!("Failed to acquire session manager lock: {}", e))
1450 })?;
1451
1452 let sessions = session_manager
1453 .list_sessions()
1454 .map_err(|e| CliError::ConfigError(format!("Failed to list sessions: {}", e)))?;
1455
1456 let matched_session = if session_id.len() >= 8 {
1457 sessions
1459 .iter()
1460 .find(|s| s.id == session_id)
1461 .or_else(|| sessions.iter().find(|s| s.id.starts_with(session_id)))
1463 } else {
1464 sessions.iter().find(|s| s.id.starts_with(session_id))
1466 };
1467
1468 match matched_session {
1469 Some(info) => {
1470 let full_id = info.id.clone();
1471 let msgs = session_manager.load_session(&full_id).map_err(|e| {
1473 CliError::ConfigError(format!("Failed to load session {}: {}", full_id, e))
1474 })?;
1475 (full_id, info.clone(), msgs)
1476 }
1477 None => {
1478 let msg = Message::system(format!("❌ Session not found: {}", session_id));
1479 if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1480 chat.add_message(msg);
1481 }
1482 return Ok(());
1483 }
1484 }
1485 };
1486
1487 if let Ok(mut id_guard) = self.tui_bridge.session_id.try_lock() {
1489 *id_guard = full_session_id.clone();
1490 }
1491 if let Ok(mut input_guard) = self.tui_bridge.total_input_tokens.try_lock() {
1492 *input_guard = session_info.total_input_tokens;
1493 }
1494 if let Ok(mut output_guard) = self.tui_bridge.total_output_tokens.try_lock() {
1495 *output_guard = session_info.total_output_tokens;
1496 }
1497
1498 if let Ok(mut messages_guard) = self.tui_bridge.messages.try_lock() {
1500 *messages_guard = messages.clone();
1501 }
1502
1503 if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1505 chat.clear();
1506
1507 for msg in &messages {
1509 match msg.role {
1510 limit_llm::Role::User => {
1511 let content = msg.content.as_deref().unwrap_or("");
1512 let chat_msg = Message::user(content.to_string());
1513 chat.add_message(chat_msg);
1514 }
1515 limit_llm::Role::Assistant => {
1516 let content = msg.content.as_deref().unwrap_or("");
1517 let chat_msg = Message::assistant(content.to_string());
1518 chat.add_message(chat_msg);
1519 }
1520 _ => {}
1521 }
1522 }
1523
1524 tracing::info!(
1525 "Loaded session: {} ({} messages)",
1526 full_session_id,
1527 messages.len()
1528 );
1529
1530 let session_short_id = if full_session_id.len() > 8 {
1532 &full_session_id[full_session_id.len().saturating_sub(8)..]
1533 } else {
1534 &full_session_id
1535 };
1536 let msg = Message::system(format!(
1537 "📂 Loaded session: {} ({} messages, {} in tokens, {} out tokens)",
1538 session_short_id,
1539 messages.len(),
1540 session_info.total_input_tokens,
1541 session_info.total_output_tokens
1542 ));
1543 chat.add_message(msg);
1544 }
1545
1546 Ok(())
1547 }
1548
1549 fn handle_share(&mut self, format_str: &str) -> Result<(), CliError> {
1551 use crate::session_share::{ExportFormat, SessionShare};
1552
1553 tracing::info!("Share command detected with format: {:?}", format_str);
1554
1555 let format = match format_str.to_lowercase().as_str() {
1557 "" | "clipboard" | "cb" => ExportFormat::Markdown,
1558 "md" | "markdown" => ExportFormat::Markdown,
1559 "json" => ExportFormat::Json,
1560 _ => {
1561 let msg = Message::system(
1562 "Invalid format. Use: /share, /share md, /share json".to_string(),
1563 );
1564 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1565 return Ok(());
1566 }
1567 };
1568
1569 let session_id = self.tui_bridge.session_id();
1571 let messages = self
1572 .tui_bridge
1573 .messages
1574 .lock()
1575 .map(|guard| guard.clone())
1576 .unwrap_or_default();
1577 let total_input_tokens = self.tui_bridge.total_input_tokens();
1578 let total_output_tokens = self.tui_bridge.total_output_tokens();
1579 let model = self
1580 .tui_bridge
1581 .agent_bridge
1582 .lock()
1583 .ok()
1584 .map(|bridge| bridge.model().to_string());
1585
1586 let user_assistant_count = messages
1588 .iter()
1589 .filter(|m| matches!(m.role, limit_llm::Role::User | limit_llm::Role::Assistant))
1590 .count();
1591
1592 if user_assistant_count == 0 {
1593 let msg =
1594 Message::system("⚠ No messages to share. Start a conversation first.".to_string());
1595 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1596 return Ok(());
1597 }
1598
1599 if format_str.is_empty() || format_str == "clipboard" || format_str == "cb" {
1601 match SessionShare::generate_share_content(
1603 &session_id,
1604 &messages,
1605 total_input_tokens,
1606 total_output_tokens,
1607 model.clone(),
1608 format,
1609 ) {
1610 Ok(content) => {
1611 if let Some(ref clipboard) = self.clipboard {
1612 match clipboard.set_text(&content) {
1613 Ok(()) => {
1614 let short_id = &session_id[..session_id.len().min(8)];
1615 let msg = Message::system(format!(
1616 "✓ Session {} copied to clipboard ({} messages, {} tokens)",
1617 short_id,
1618 user_assistant_count,
1619 total_input_tokens + total_output_tokens
1620 ));
1621 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1622 }
1623 Err(e) => {
1624 let msg = Message::system(format!(
1625 "❌ Failed to copy to clipboard: {}",
1626 e
1627 ));
1628 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1629 }
1630 }
1631 } else {
1632 let msg = Message::system(
1633 "❌ Clipboard not available. Try '/share md' to save as file."
1634 .to_string(),
1635 );
1636 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1637 }
1638 }
1639 Err(e) => {
1640 let msg =
1641 Message::system(format!("❌ Failed to generate share content: {}", e));
1642 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1643 }
1644 }
1645 } else {
1646 match SessionShare::export_session(
1648 &session_id,
1649 &messages,
1650 total_input_tokens,
1651 total_output_tokens,
1652 model,
1653 format,
1654 ) {
1655 Ok((filepath, export)) => {
1656 let short_id = &session_id[..session_id.len().min(8)];
1657 let extension = match format {
1658 ExportFormat::Markdown => "md",
1659 ExportFormat::Json => "json",
1660 };
1661 let msg = Message::system(format!(
1662 "✓ Session {} exported to {}\n ({} messages, {} tokens)\n Location: ~/.limit/exports/",
1663 short_id,
1664 extension,
1665 user_assistant_count,
1666 total_input_tokens + total_output_tokens
1667 ));
1668 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1669
1670 tracing::info!(
1671 "Session exported to {:?} ({} messages)",
1672 filepath,
1673 export.messages.len()
1674 );
1675 }
1676 Err(e) => {
1677 let msg = Message::system(format!("❌ Failed to export session: {}", e));
1678 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1679 }
1680 }
1681 }
1682
1683 Ok(())
1684 }
1685
1686 fn prev_char_pos(&self) -> usize {
1687 if self.cursor_pos == 0 {
1688 return 0;
1689 }
1690 let mut pos = self.cursor_pos - 1;
1692 while pos > 0 && !self.input_text.is_char_boundary(pos) {
1693 pos -= 1;
1694 }
1695 pos
1696 }
1697
1698 fn next_char_pos(&self) -> usize {
1699 if self.cursor_pos >= self.input_text.len() {
1700 return self.input_text.len();
1701 }
1702 let mut pos = self.cursor_pos + 1;
1704 while pos < self.input_text.len() && !self.input_text.is_char_boundary(pos) {
1705 pos += 1;
1706 }
1707 pos
1708 }
1709
1710 fn draw(&mut self) -> Result<(), CliError> {
1711 let chat_view = self.tui_bridge.chat_view().clone();
1712 let state = self.tui_bridge.state();
1713 let input_text = self.input_text.clone();
1714 let cursor_pos = self.cursor_pos;
1715 let status_message = self.status_message.clone();
1716 let status_is_error = self.status_is_error;
1717 let cursor_blink_state = self.cursor_blink_state;
1718 let tui_bridge = &self.tui_bridge;
1719 let file_autocomplete = self.file_autocomplete.clone();
1720
1721 self.terminal
1722 .draw(|f| {
1723 Self::draw_ui(
1724 f,
1725 &chat_view,
1726 state,
1727 &input_text,
1728 cursor_pos,
1729 &status_message,
1730 status_is_error,
1731 cursor_blink_state,
1732 tui_bridge,
1733 &file_autocomplete,
1734 );
1735 })
1736 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
1737
1738 Ok(())
1739 }
1740
1741 #[allow(clippy::too_many_arguments)]
1743 fn draw_ui(
1744 f: &mut Frame,
1745 chat_view: &Arc<Mutex<ChatView>>,
1746 _state: TuiState,
1747 input_text: &str,
1748 cursor_pos: usize,
1749 status_message: &str,
1750 status_is_error: bool,
1751 cursor_blink_state: bool,
1752 tui_bridge: &TuiBridge,
1753 file_autocomplete: &Option<FileAutocompleteState>,
1754 ) {
1755 let size = f.area();
1756
1757 let activity_count = tui_bridge.activity_feed().lock().unwrap().len();
1759 let activity_height = if activity_count > 0 {
1760 (activity_count as u16).min(3) } else {
1762 0
1763 };
1764
1765 let constraints: Vec<Constraint> = vec![Constraint::Percentage(90)]; let mut constraints = constraints;
1768 if activity_height > 0 {
1769 constraints.push(Constraint::Length(activity_height)); }
1771 constraints.push(Constraint::Length(1)); constraints.push(Constraint::Length(6)); let chunks = Layout::default()
1776 .direction(Direction::Vertical)
1777 .constraints(constraints.as_slice())
1778 .split(size);
1779
1780 let mut chunk_idx = 0;
1781
1782 {
1784 let chat = chat_view.lock().unwrap();
1785 let total_input = tui_bridge.total_input_tokens();
1786 let total_output = tui_bridge.total_output_tokens();
1787 let title = format!(" Chat (↑{} ↓{}) ", total_input, total_output);
1788 let chat_block = Block::default()
1789 .borders(Borders::ALL)
1790 .title(title)
1791 .title_style(
1792 Style::default()
1793 .fg(Color::Cyan)
1794 .add_modifier(Modifier::BOLD),
1795 );
1796 f.render_widget(&*chat, chat_block.inner(chunks[chunk_idx]));
1797 f.render_widget(chat_block, chunks[chunk_idx]);
1798 chunk_idx += 1;
1799 }
1800
1801 if activity_height > 0 {
1803 let activity_feed = tui_bridge.activity_feed().lock().unwrap();
1804 let activity_block = Block::default()
1805 .borders(Borders::NONE)
1806 .style(Style::default().bg(Color::Reset));
1807 let activity_inner = activity_block.inner(chunks[chunk_idx]);
1808 f.render_widget(activity_block, chunks[chunk_idx]);
1809 activity_feed.render(activity_inner, f.buffer_mut());
1810 chunk_idx += 1;
1811 }
1812
1813 {
1815 let status_style = if status_is_error {
1816 Style::default().fg(Color::Red).bg(Color::Reset)
1817 } else {
1818 Style::default().fg(Color::Yellow)
1819 };
1820
1821 let status = Paragraph::new(Line::from(vec![
1822 Span::styled(" ● ", Style::default().fg(Color::Green)),
1823 Span::styled(status_message, status_style),
1824 ]));
1825 f.render_widget(status, chunks[chunk_idx]);
1826 chunk_idx += 1;
1827 }
1828
1829 {
1831 let input_block = Block::default()
1832 .borders(Borders::ALL)
1833 .title(" Input (Esc or /exit to quit) ")
1834 .title_style(Style::default().fg(Color::Cyan));
1835
1836 let input_inner = input_block.inner(chunks[chunk_idx]);
1837 f.render_widget(input_block, chunks[chunk_idx]);
1838
1839 let before_cursor = &input_text[..cursor_pos];
1841 let at_cursor = if cursor_pos < input_text.len() {
1842 &input_text[cursor_pos
1843 ..cursor_pos
1844 + input_text[cursor_pos..]
1845 .chars()
1846 .next()
1847 .map(|c| c.len_utf8())
1848 .unwrap_or(0)]
1849 } else {
1850 " "
1851 };
1852 let after_cursor = if cursor_pos < input_text.len() {
1853 &input_text[cursor_pos + at_cursor.len()..]
1854 } else {
1855 ""
1856 };
1857
1858 let cursor_style = if cursor_blink_state {
1859 Style::default().bg(Color::White).fg(Color::Black)
1860 } else {
1861 Style::default().bg(Color::Reset).fg(Color::Reset)
1862 };
1863
1864 let input_line = if input_text.is_empty() {
1865 Line::from(vec![Span::styled(
1866 "Type your message here...",
1867 Style::default().fg(Color::DarkGray),
1868 )])
1869 } else {
1870 Line::from(vec![
1871 Span::raw(before_cursor),
1872 Span::styled(at_cursor, cursor_style),
1873 Span::raw(after_cursor),
1874 ])
1875 };
1876
1877 let input_para = Paragraph::new(input_line).wrap(Wrap { trim: false });
1878 f.render_widget(input_para, input_inner);
1879 }
1880
1881 if let Some(ref ac) = file_autocomplete {
1883 if ac.is_active && !ac.matches.is_empty() {
1884 let input_area = chunks.last().unwrap();
1886
1887 let popup_area = calculate_popup_area(*input_area, ac.matches.len());
1889
1890 let widget = FileAutocompleteWidget::new(&ac.matches, ac.selected_index, &ac.query);
1892 f.render_widget(widget, popup_area);
1893 }
1894 }
1895 }
1896}
1897
1898#[cfg(test)]
1899mod tests {
1900 use super::*;
1901
1902 fn create_test_config() -> limit_llm::Config {
1904 use limit_llm::ProviderConfig;
1905 let mut providers = std::collections::HashMap::new();
1906 providers.insert(
1907 "anthropic".to_string(),
1908 ProviderConfig {
1909 api_key: Some("test-key".to_string()),
1910 model: "claude-3-5-sonnet-20241022".to_string(),
1911 base_url: None,
1912 max_tokens: 4096,
1913 timeout: 60,
1914 max_iterations: 100,
1915 thinking_enabled: false,
1916 clear_thinking: true,
1917 },
1918 );
1919 limit_llm::Config {
1920 provider: "anthropic".to_string(),
1921 providers,
1922 }
1923 }
1924
1925 #[test]
1926 fn test_tui_bridge_new() {
1927 let config = create_test_config();
1928 let agent_bridge = AgentBridge::new(config).unwrap();
1929 let (_tx, rx) = mpsc::unbounded_channel();
1930
1931 let tui_bridge = TuiBridge::new(agent_bridge, rx).unwrap();
1932 assert_eq!(tui_bridge.state(), TuiState::Idle);
1933 }
1934
1935 #[test]
1936 fn test_tui_bridge_state() {
1937 let config = create_test_config();
1938 let agent_bridge = AgentBridge::new(config).unwrap();
1939 let (tx, rx) = mpsc::unbounded_channel();
1940
1941 let mut tui_bridge = TuiBridge::new(agent_bridge, rx).unwrap();
1942
1943 tx.send(AgentEvent::Thinking).unwrap();
1944 tui_bridge.process_events().unwrap();
1945 assert!(matches!(tui_bridge.state(), TuiState::Thinking));
1946
1947 tx.send(AgentEvent::Done).unwrap();
1948 tui_bridge.process_events().unwrap();
1949 assert_eq!(tui_bridge.state(), TuiState::Idle);
1950 }
1951
1952 #[test]
1953 fn test_tui_bridge_chat_view() {
1954 let config = create_test_config();
1955 let agent_bridge = AgentBridge::new(config).unwrap();
1956 let (_tx, rx) = mpsc::unbounded_channel();
1957
1958 let tui_bridge = TuiBridge::new(agent_bridge, rx).unwrap();
1959
1960 tui_bridge.add_user_message("Hello".to_string());
1961 assert_eq!(tui_bridge.chat_view().lock().unwrap().message_count(), 3); }
1963
1964 #[test]
1965 fn test_tui_state_default() {
1966 let state = TuiState::default();
1967 assert_eq!(state, TuiState::Idle);
1968 }
1969}