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 operation_id: Arc<Mutex<u64>>,
94}
95
96impl TuiBridge {
97 pub fn new(
99 agent_bridge: AgentBridge,
100 event_rx: mpsc::UnboundedReceiver<AgentEvent>,
101 ) -> Result<Self, CliError> {
102 let session_manager = SessionManager::new().map_err(|e| {
103 CliError::ConfigError(format!("Failed to create session manager: {}", e))
104 })?;
105
106 let session_id = session_manager
108 .create_new_session()
109 .map_err(|e| CliError::ConfigError(format!("Failed to create session: {}", e)))?;
110 tracing::info!("Created new TUI session: {}", session_id);
111
112 let messages: Vec<limit_llm::Message> = Vec::new();
114
115 let sessions = session_manager.list_sessions().unwrap_or_default();
117 let session_info = sessions.iter().find(|s| s.id == session_id);
118 let initial_input = session_info.map(|s| s.total_input_tokens).unwrap_or(0);
119 let initial_output = session_info.map(|s| s.total_output_tokens).unwrap_or(0);
120
121 let chat_view = Arc::new(Mutex::new(ChatView::new()));
122
123 for msg in &messages {
125 match msg.role {
126 limit_llm::Role::User => {
127 let chat_msg = Message::user(msg.content.clone().unwrap_or_default());
128 chat_view.lock().unwrap().add_message(chat_msg);
129 }
130 limit_llm::Role::Assistant => {
131 let content = msg.content.clone().unwrap_or_default();
132 let chat_msg = Message::assistant(content);
133 chat_view.lock().unwrap().add_message(chat_msg);
134 }
135 limit_llm::Role::System => {
136 }
138 limit_llm::Role::Tool => {
139 }
141 }
142 }
143
144 tracing::info!("Loaded {} messages into chat view", messages.len());
145
146 let session_short_id = format!("...{}", &session_id[session_id.len().saturating_sub(8)..]);
148 let welcome_msg =
149 Message::system(format!("🆕 New TUI session started: {}", session_short_id));
150 chat_view.lock().unwrap().add_message(welcome_msg);
151
152 let model_name = agent_bridge.model().to_string();
154 if !model_name.is_empty() {
155 let model_msg = Message::system(format!("Using model: {}", model_name));
156 chat_view.lock().unwrap().add_message(model_msg);
157 }
158
159 Ok(Self {
160 agent_bridge: Arc::new(Mutex::new(agent_bridge)),
161 event_rx,
162 state: Arc::new(Mutex::new(TuiState::Idle)),
163 chat_view,
164 activity_feed: Arc::new(Mutex::new(ActivityFeed::new())),
165 spinner: Arc::new(Mutex::new(Spinner::new("Thinking..."))),
166 messages: Arc::new(Mutex::new(messages)),
167 total_input_tokens: Arc::new(Mutex::new(initial_input)),
168 total_output_tokens: Arc::new(Mutex::new(initial_output)),
169 session_manager: Arc::new(Mutex::new(session_manager)),
170 session_id: Arc::new(Mutex::new(session_id)),
171 operation_id: Arc::new(Mutex::new(0)),
172 })
173 }
174
175 pub fn agent_bridge_arc(&self) -> Arc<Mutex<AgentBridge>> {
177 self.agent_bridge.clone()
178 }
179
180 #[allow(dead_code)]
182 pub fn agent_bridge(&self) -> std::sync::MutexGuard<'_, AgentBridge> {
183 self.agent_bridge.lock().unwrap()
184 }
185
186 pub fn state(&self) -> TuiState {
188 self.state.lock().unwrap().clone()
189 }
190
191 pub fn chat_view(&self) -> &Arc<Mutex<ChatView>> {
193 &self.chat_view
194 }
195
196 pub fn spinner(&self) -> &Arc<Mutex<Spinner>> {
198 &self.spinner
199 }
200
201 pub fn activity_feed(&self) -> &Arc<Mutex<ActivityFeed>> {
203 &self.activity_feed
204 }
205
206 pub fn process_events(&mut self) -> Result<(), CliError> {
208 let mut event_count = 0;
209 let current_op_id = self.operation_id();
210
211 while let Ok(event) = self.event_rx.try_recv() {
212 event_count += 1;
213
214 let event_op_id = match &event {
216 AgentEvent::Thinking { operation_id } => *operation_id,
217 AgentEvent::ToolStart { operation_id, .. } => *operation_id,
218 AgentEvent::ToolComplete { operation_id, .. } => *operation_id,
219 AgentEvent::ContentChunk { operation_id, .. } => *operation_id,
220 AgentEvent::Done { operation_id } => *operation_id,
221 AgentEvent::Cancelled { operation_id } => *operation_id,
222 AgentEvent::Error { operation_id, .. } => *operation_id,
223 AgentEvent::TokenUsage { operation_id, .. } => *operation_id,
224 };
225
226 debug_log(&format!(
227 "process_events: event_op_id={}, current_op_id={}, event={:?}",
228 event_op_id, current_op_id, std::mem::discriminant(&event)
229 ));
230
231 if event_op_id != current_op_id {
233 debug_log(&format!(
234 "process_events: Ignoring event from old operation {} (current: {})",
235 event_op_id, current_op_id
236 ));
237 continue;
238 }
239
240 match event {
241 AgentEvent::Thinking { operation_id: _ } => {
242 debug_log("process_events: Thinking event received - setting state to Thinking");
243 *self.state.lock().unwrap() = TuiState::Thinking;
244 debug_log(&format!("process_events: state is now {:?}", self.state()));
245 }
246 AgentEvent::ToolStart {
247 operation_id: _,
248 name,
249 args,
250 } => {
251 debug_log(&format!("process_events: ToolStart event - {}", name));
252 let activity_msg = Self::format_activity_message(&name, &args);
253 self.activity_feed.lock().unwrap().add(activity_msg, true);
255 }
256 AgentEvent::ToolComplete {
257 operation_id: _,
258 name: _,
259 result: _,
260 } => {
261 debug_log("process_events: ToolComplete event");
262 self.activity_feed.lock().unwrap().complete_current();
264 }
265 AgentEvent::ContentChunk {
266 operation_id: _,
267 chunk,
268 } => {
269 debug_log(&format!(
270 "process_events: ContentChunk event ({} chars)",
271 chunk.len()
272 ));
273 self.chat_view
274 .lock()
275 .unwrap()
276 .append_to_last_assistant(&chunk);
277 }
278 AgentEvent::Done { operation_id: _ } => {
279 debug_log("process_events: Done event received");
280 *self.state.lock().unwrap() = TuiState::Idle;
281 self.activity_feed.lock().unwrap().complete_all();
283 }
284 AgentEvent::Cancelled { operation_id: _ } => {
285 debug_log("process_events: Cancelled event received");
286 *self.state.lock().unwrap() = TuiState::Idle;
287 self.activity_feed.lock().unwrap().complete_all();
289 }
290 AgentEvent::Error {
291 operation_id: _,
292 message,
293 } => {
294 debug_log(&format!("process_events: Error event - {}", message));
295 *self.state.lock().unwrap() = TuiState::Idle;
297 let chat_msg = Message::system(format!("Error: {}", message));
298 self.chat_view.lock().unwrap().add_message(chat_msg);
299 }
300 AgentEvent::TokenUsage {
301 operation_id: _,
302 input_tokens,
303 output_tokens,
304 } => {
305 debug_log(&format!(
306 "process_events: TokenUsage event - in={}, out={}",
307 input_tokens, output_tokens
308 ));
309 *self.total_input_tokens.lock().unwrap() += input_tokens;
311 *self.total_output_tokens.lock().unwrap() += output_tokens;
312 }
313 }
314 }
315 if event_count > 0 {
316 debug_log(&format!("process_events: processed {} events", event_count));
317 }
318 Ok(())
319 }
320
321 fn format_activity_message(tool_name: &str, args: &serde_json::Value) -> String {
322 match tool_name {
323 "file_read" => args
324 .get("path")
325 .and_then(|p| p.as_str())
326 .map(|p| format!("Reading {}...", Self::truncate_path(p, 40)))
327 .unwrap_or_else(|| "Reading file...".to_string()),
328 "file_write" => args
329 .get("path")
330 .and_then(|p| p.as_str())
331 .map(|p| format!("Writing {}...", Self::truncate_path(p, 40)))
332 .unwrap_or_else(|| "Writing file...".to_string()),
333 "file_edit" => args
334 .get("path")
335 .and_then(|p| p.as_str())
336 .map(|p| format!("Editing {}...", Self::truncate_path(p, 40)))
337 .unwrap_or_else(|| "Editing file...".to_string()),
338 "bash" => args
339 .get("command")
340 .and_then(|c| c.as_str())
341 .map(|c| format!("Running {}...", Self::truncate_command(c, 30)))
342 .unwrap_or_else(|| "Executing command...".to_string()),
343 "git_status" => "Checking git status...".to_string(),
344 "git_diff" => "Checking git diff...".to_string(),
345 "git_log" => "Checking git log...".to_string(),
346 "git_add" => "Staging files...".to_string(),
347 "git_commit" => "Creating commit...".to_string(),
348 "git_push" => "Pushing to remote...".to_string(),
349 "git_pull" => "Pulling from remote...".to_string(),
350 "git_clone" => args
351 .get("url")
352 .and_then(|u| u.as_str())
353 .map(|u| format!("Cloning {}...", Self::truncate_path(u, 40)))
354 .unwrap_or_else(|| "Cloning repository...".to_string()),
355 "grep" => args
356 .get("pattern")
357 .and_then(|p| p.as_str())
358 .map(|p| format!("Searching for '{}'...", Self::truncate_command(p, 30)))
359 .unwrap_or_else(|| "Searching...".to_string()),
360 "ast_grep" => args
361 .get("pattern")
362 .and_then(|p| p.as_str())
363 .map(|p| format!("AST searching '{}'...", Self::truncate_command(p, 25)))
364 .unwrap_or_else(|| "AST searching...".to_string()),
365 "lsp" => args
366 .get("command")
367 .and_then(|c| c.as_str())
368 .map(|c| format!("Running LSP {}...", c))
369 .unwrap_or_else(|| "Running LSP...".to_string()),
370 _ => format!("Executing {}...", tool_name),
371 }
372 }
373
374 fn truncate_path(s: &str, max_len: usize) -> String {
375 if s.len() <= max_len {
376 s.to_string()
377 } else {
378 format!("...{}", &s[s.len().saturating_sub(max_len - 3)..])
379 }
380 }
381
382 fn truncate_command(s: &str, max_len: usize) -> String {
383 if s.len() <= max_len {
384 s.to_string()
385 } else {
386 format!("{}...", &s[..max_len.saturating_sub(3)])
387 }
388 }
389
390 pub fn add_user_message(&self, content: String) {
392 let msg = Message::user(content);
393 self.chat_view.lock().unwrap().add_message(msg);
394 }
395
396 pub fn tick_spinner(&self) {
398 self.spinner.lock().unwrap().tick();
399 }
400
401 pub fn is_busy(&self) -> bool {
403 !matches!(self.state(), TuiState::Idle)
404 }
405
406 pub fn operation_id(&self) -> u64 {
408 self.operation_id.lock().map(|id| *id).unwrap_or(0)
409 }
410
411 pub fn next_operation_id(&self) -> u64 {
413 if let Ok(mut id) = self.operation_id.lock() {
414 *id += 1;
415 *id
416 } else {
417 0
418 }
419 }
420
421 pub fn total_input_tokens(&self) -> u64 {
423 self.total_input_tokens
424 .lock()
425 .map(|guard| *guard)
426 .unwrap_or(0)
427 }
428
429 pub fn total_output_tokens(&self) -> u64 {
431 self.total_output_tokens
432 .lock()
433 .map(|guard| *guard)
434 .unwrap_or(0)
435 }
436
437 pub fn session_id(&self) -> String {
439 self.session_id
440 .lock()
441 .map(|guard| guard.clone())
442 .unwrap_or_else(|_| String::from("unknown"))
443 }
444
445 pub fn save_session(&self) -> Result<(), CliError> {
447 let session_id = self
448 .session_id
449 .lock()
450 .map(|guard| guard.clone())
451 .unwrap_or_else(|_| String::from("unknown"));
452
453 let messages = self
454 .messages
455 .lock()
456 .map(|guard| guard.clone())
457 .unwrap_or_default();
458
459 let input_tokens = self
460 .total_input_tokens
461 .lock()
462 .map(|guard| *guard)
463 .unwrap_or(0);
464
465 let output_tokens = self
466 .total_output_tokens
467 .lock()
468 .map(|guard| *guard)
469 .unwrap_or(0);
470
471 tracing::debug!(
472 "Saving session {} with {} messages, {} in tokens, {} out tokens",
473 session_id,
474 messages.len(),
475 input_tokens,
476 output_tokens
477 );
478
479 let session_manager = self.session_manager.lock().map_err(|e| {
480 CliError::ConfigError(format!("Failed to acquire session manager lock: {}", e))
481 })?;
482
483 session_manager.save_session(&session_id, &messages, input_tokens, output_tokens)?;
484 tracing::info!(
485 "✓ Session {} saved successfully ({} messages, {} in tokens, {} out tokens)",
486 session_id,
487 messages.len(),
488 input_tokens,
489 output_tokens
490 );
491 Ok(())
492 }
493}
494
495pub struct TuiApp {
497 tui_bridge: TuiBridge,
498 terminal: Terminal<CrosstermBackend<io::Stdout>>,
499 running: bool,
500 input_text: String,
501 cursor_pos: usize,
502 status_message: String,
503 status_is_error: bool,
504 cursor_blink_state: bool,
505 cursor_blink_timer: std::time::Instant,
506 mouse_selection_start: Option<(u16, u16)>,
508 clipboard: Option<ClipboardManager>,
510 file_autocomplete: Option<FileAutocompleteState>,
512 file_finder: FileFinder,
514 last_esc_time: Option<std::time::Instant>,
516 cancellation_token: Option<tokio_util::sync::CancellationToken>,
518}
519
520impl TuiApp {
521 pub fn new(tui_bridge: TuiBridge) -> Result<Self, CliError> {
523 let backend = CrosstermBackend::new(io::stdout());
524 let terminal =
525 Terminal::new(backend).map_err(|e| CliError::IoError(io::Error::other(e)))?;
526
527 let session_id = tui_bridge.session_id();
528 tracing::info!("TUI started with session: {}", session_id);
529
530 let clipboard = match ClipboardManager::new() {
531 Ok(cb) => {
532 debug_log("✓ Clipboard initialized successfully");
533 tracing::info!("Clipboard initialized successfully");
534 Some(cb)
535 }
536 Err(e) => {
537 debug_log(&format!("✗ Clipboard initialization failed: {}", e));
538 tracing::warn!("Clipboard unavailable: {}", e);
539 None
540 }
541 };
542
543 let working_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
545 let file_finder = FileFinder::new(working_dir);
546
547 Ok(Self {
548 tui_bridge,
549 terminal,
550 running: true,
551 input_text: String::new(),
552 cursor_pos: 0,
553 status_message: "Ready - Type a message and press Enter".to_string(),
554 status_is_error: false,
555 cursor_blink_state: true,
556 cursor_blink_timer: std::time::Instant::now(),
557 mouse_selection_start: None,
558 clipboard,
559 file_autocomplete: None,
560 file_finder,
561 last_esc_time: None,
562 cancellation_token: None,
563 })
564 }
565
566 pub fn run(&mut self) -> Result<(), CliError> {
568 execute!(std::io::stdout(), EnterAlternateScreen)
570 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
571
572 execute!(std::io::stdout(), EnableMouseCapture)
574 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
575
576 execute!(std::io::stdout(), EnableBracketedPaste)
578 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
579
580 crossterm::terminal::enable_raw_mode()
581 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
582
583 struct AlternateScreenGuard;
585 impl Drop for AlternateScreenGuard {
586 fn drop(&mut self) {
587 let _ = crossterm::terminal::disable_raw_mode();
588 let _ = execute!(std::io::stdout(), DisableBracketedPaste);
589 let _ = execute!(std::io::stdout(), DisableMouseCapture);
590 let _ = execute!(std::io::stdout(), LeaveAlternateScreen);
591 }
592 }
593 let _guard = AlternateScreenGuard;
594
595 self.run_inner()
596 }
597
598 fn run_inner(&mut self) -> Result<(), CliError> {
599 while self.running {
600 self.tui_bridge.process_events()?;
602
603 if matches!(self.tui_bridge.state(), TuiState::Thinking) {
605 self.tui_bridge.tick_spinner();
606 }
607
608 self.update_status();
610
611 if crossterm::event::poll(std::time::Duration::from_millis(100))
613 .map_err(|e| CliError::IoError(io::Error::other(e)))?
614 {
615 match event::read().map_err(|e| CliError::IoError(io::Error::other(e)))? {
616 Event::Key(key) => {
617 debug_log(&format!(
619 "Event::Key - code={:?} mod={:?} kind={:?}",
620 key.code, key.modifiers, key.kind
621 ));
622
623 if key.kind == KeyEventKind::Press {
624 self.handle_key_event(key)?;
625 }
626 }
627 Event::Mouse(mouse) => {
628 match mouse.kind {
629 MouseEventKind::Down(MouseButton::Left) => {
630 debug_log(&format!(
631 "MouseDown at ({}, {})",
632 mouse.column, mouse.row
633 ));
634 self.mouse_selection_start = Some((mouse.column, mouse.row));
635 let chat = self.tui_bridge.chat_view().lock().unwrap();
637 debug_log(&format!(
638 " render_positions count: {}",
639 chat.render_position_count()
640 ));
641 if let Some((msg_idx, char_offset)) =
642 chat.screen_to_text_pos(mouse.column, mouse.row)
643 {
644 debug_log(&format!(
645 " -> Starting selection at msg={}, offset={}",
646 msg_idx, char_offset
647 ));
648 drop(chat);
649 self.tui_bridge
650 .chat_view()
651 .lock()
652 .unwrap()
653 .start_selection(msg_idx, char_offset);
654 } else {
655 debug_log(" -> No match, clearing selection");
656 drop(chat);
657 self.tui_bridge
658 .chat_view()
659 .lock()
660 .unwrap()
661 .clear_selection();
662 }
663 }
664 MouseEventKind::Drag(MouseButton::Left) => {
665 if self.mouse_selection_start.is_some() {
666 let chat = self.tui_bridge.chat_view().lock().unwrap();
668 if let Some((msg_idx, char_offset)) =
669 chat.screen_to_text_pos(mouse.column, mouse.row)
670 {
671 drop(chat);
672 self.tui_bridge
673 .chat_view()
674 .lock()
675 .unwrap()
676 .extend_selection(msg_idx, char_offset);
677 }
678 }
679 }
680 MouseEventKind::Up(MouseButton::Left) => {
681 self.mouse_selection_start = None;
682 }
683 MouseEventKind::ScrollUp => {
684 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
685 chat.scroll_up();
686 }
687 MouseEventKind::ScrollDown => {
688 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
689 chat.scroll_down();
690 }
691 _ => {}
692 }
693 }
694 Event::Paste(pasted) => {
695 if !self.tui_bridge.is_busy() {
696 self.insert_paste(&pasted);
697 }
698 }
699 _ => {}
700 }
701 } else {
702 self.tick_cursor_blink();
704 }
705
706 self.draw()?;
708 }
709
710 if let Err(e) = self.tui_bridge.save_session() {
712 tracing::error!("Failed to save session: {}", e);
713 }
714
715 Ok(())
716 }
717
718 fn update_status(&mut self) {
719 let session_id = self.tui_bridge.session_id();
720 let has_activity = self
721 .tui_bridge
722 .activity_feed()
723 .lock()
724 .unwrap()
725 .has_in_progress();
726
727 match self.tui_bridge.state() {
728 TuiState::Idle => {
729 if has_activity {
730 let spinner = self.tui_bridge.spinner().lock().unwrap();
732 self.status_message = format!("{} Processing...", spinner.current_frame());
733 } else {
734 self.status_message = format!(
735 "Ready | Session: {}",
736 session_id.chars().take(8).collect::<String>()
737 );
738 }
739 self.status_is_error = false;
740 }
741 TuiState::Thinking => {
742 let spinner = self.tui_bridge.spinner().lock().unwrap();
743 self.status_message = format!("{} Thinking...", spinner.current_frame());
744 self.status_is_error = false;
745 }
746 }
747 }
748
749 fn insert_paste(&mut self, text: &str) {
751 let text = if text.len() > MAX_PASTE_SIZE {
753 self.status_message = "Paste truncated (too large)".to_string();
754 self.status_is_error = true;
755 &text[..text
757 .char_indices()
758 .nth(MAX_PASTE_SIZE)
759 .map(|(i, _)| i)
760 .unwrap_or(text.len())]
761 } else {
762 text
763 };
764
765 let normalized = text.replace("\r", "\n");
767 self.input_text.insert_str(self.cursor_pos, &normalized);
768 self.cursor_pos += normalized.len();
769 }
770
771 fn is_copy_paste_modifier(&self, key: &KeyEvent, char: char) -> bool {
775 #[cfg(target_os = "macos")]
776 {
777 let has_super = key.modifiers.contains(KeyModifiers::SUPER);
779 let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
780 let result = key.code == KeyCode::Char(char) && (has_super || has_ctrl);
781 debug_log(&format!("is_copy_paste_modifier('{}') macOS: code={:?}, mod={:?}, super={}, ctrl={}, result={}",
782 char, key.code, key.modifiers, has_super, has_ctrl, result));
783 result
784 }
785 #[cfg(not(target_os = "macos"))]
786 {
787 let result =
788 key.code == KeyCode::Char(char) && key.modifiers.contains(KeyModifiers::CONTROL);
789 debug_log(&format!(
790 "is_copy_paste_modifier('{}') non-macOS: code={:?}, mod={:?}, ctrl={:?}, result={}",
791 char,
792 key.code,
793 key.modifiers,
794 KeyModifiers::CONTROL,
795 result
796 ));
797 result
798 }
799 }
800
801 fn tick_cursor_blink(&mut self) {
802 if self.cursor_blink_timer.elapsed().as_millis() > 500 {
804 self.cursor_blink_state = !self.cursor_blink_state;
805 self.cursor_blink_timer = std::time::Instant::now();
806 }
807 }
808
809 fn handle_key_event(&mut self, key: KeyEvent) -> Result<(), CliError> {
810 debug_log(&format!(
812 "handle_key_event: code={:?} mod={:?} kind={:?}",
813 key.code, key.modifiers, key.kind
814 ));
815
816 if matches!(key.code, KeyCode::Char('c') | KeyCode::Char('v')) {
818 debug_log(&format!(
819 ">>> SPECIAL: '{}' key detected with modifiers: {:?} (SUPER={:?}, CONTROL={:?})",
820 if matches!(key.code, KeyCode::Char('c')) {
821 'c'
822 } else {
823 'v'
824 },
825 key.modifiers,
826 key.modifiers.contains(KeyModifiers::SUPER),
827 key.modifiers.contains(KeyModifiers::CONTROL)
828 ));
829 }
830
831 if self.is_copy_paste_modifier(&key, 'c') {
833 debug_log("✓ Copy shortcut CONFIRMED - processing...");
834 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
835 let has_selection = chat.has_selection();
836 debug_log(&format!("has_selection={}", has_selection));
837
838 if has_selection {
839 if let Some(selected) = chat.get_selected_text() {
840 debug_log(&format!("Selected text length={}", selected.len()));
841 if !selected.is_empty() {
842 if let Some(ref clipboard) = self.clipboard {
843 debug_log("Attempting to copy to clipboard...");
844 match clipboard.set_text(&selected) {
845 Ok(()) => {
846 debug_log("✓ Clipboard copy successful");
847 self.status_message = "Copied to clipboard".to_string();
848 self.status_is_error = false;
849 }
850 Err(e) => {
851 debug_log(&format!("✗ Clipboard copy failed: {}", e));
852 self.status_message = format!("Clipboard error: {}", e);
853 self.status_is_error = true;
854 }
855 }
856 } else {
857 debug_log("✗ Clipboard not available (None)");
858 self.status_message = "Clipboard not available".to_string();
859 self.status_is_error = true;
860 }
861 } else {
862 debug_log("Selected text is empty");
863 }
864 chat.clear_selection();
865 } else {
866 debug_log("get_selected_text() returned None");
867 }
868 return Ok(());
869 }
870
871 debug_log("Ctrl/Cmd+C with no selection - ignoring");
873 return Ok(());
874 }
875
876 if self.is_copy_paste_modifier(&key, 'v') && !self.tui_bridge.is_busy() {
878 debug_log("✓ Paste shortcut CONFIRMED - processing...");
879 if let Some(ref clipboard) = self.clipboard {
880 debug_log("Attempting to read from clipboard...");
881 match clipboard.get_text() {
882 Ok(text) if !text.is_empty() => {
883 debug_log(&format!("Read {} chars from clipboard", text.len()));
884 self.insert_paste(&text);
885 }
886 Ok(_) => {
887 debug_log("Clipboard is empty");
888 } Err(e) => {
890 debug_log(&format!("✗ Failed to read clipboard: {}", e));
891 self.status_message = format!("Could not read clipboard: {}", e);
892 self.status_is_error = true;
893 }
894 }
895 } else {
896 debug_log("✗ Clipboard not available (None)");
897 self.status_message = "Clipboard not available".to_string();
898 self.status_is_error = true;
899 }
900 return Ok(());
901 }
902
903 if let Some(ref mut ac) = self.file_autocomplete {
905 if ac.is_active {
906 match key.code {
907 KeyCode::Up => {
908 if ac.selected_index > 0 {
909 ac.selected_index -= 1;
910 }
911 return Ok(());
912 }
913 KeyCode::Down => {
914 if ac.selected_index + 1 < ac.matches.len() {
915 ac.selected_index += 1;
916 }
917 return Ok(());
918 }
919 KeyCode::Enter | KeyCode::Tab => {
920 self.accept_file_completion();
922 return Ok(());
923 }
924 KeyCode::Esc => {
925 self.file_autocomplete = None;
927 return Ok(());
928 }
929 _ => {}
930 }
931 }
932 }
933
934 if key.code == KeyCode::Esc {
936 if self.file_autocomplete.is_some() {
938 self.file_autocomplete = None;
939 } else if self.tui_bridge.is_busy() {
940 let now = std::time::Instant::now();
942 let should_cancel = if let Some(last_esc) = self.last_esc_time {
943 now.duration_since(last_esc) < std::time::Duration::from_millis(1000)
944 } else {
945 false
946 };
947
948 if should_cancel {
949 self.cancel_current_operation();
950 } else {
951 self.status_message = "Press ESC again to cancel".to_string();
953 self.status_is_error = false;
954 self.last_esc_time = Some(now);
955 }
956 } else {
957 debug_log("Esc pressed, exiting");
958 self.running = false;
959 }
960 return Ok(());
961 }
962
963 let term_height = self.terminal.size().map(|s| s.height).unwrap_or(24);
966 let viewport_height = term_height
967 .saturating_sub(1) .saturating_sub(7); match key.code {
970 KeyCode::PageUp => {
971 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
972 chat.scroll_page_up(viewport_height);
973 return Ok(());
974 }
975 KeyCode::PageDown => {
976 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
977 chat.scroll_page_down(viewport_height);
978 return Ok(());
979 }
980 KeyCode::Up => {
981 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
982 chat.scroll_up();
983 return Ok(());
984 }
985 KeyCode::Down => {
986 let mut chat = self.tui_bridge.chat_view().lock().unwrap();
988 chat.scroll_down();
989 return Ok(());
990 }
991 _ => {}
992 }
993
994 if self.tui_bridge.is_busy() {
996 debug_log("Agent busy, ignoring");
997 return Ok(());
998 }
999
1000 if self.handle_backspace(&key) {
1002 debug_log(&format!("Backspace handled, input: {:?}", self.input_text));
1003 return Ok(());
1004 }
1005
1006 match key.code {
1007 KeyCode::Delete => {
1008 if self.cursor_pos < self.input_text.len() {
1009 let next_pos = self.next_char_pos();
1010 self.input_text.drain(self.cursor_pos..next_pos);
1011 debug_log(&format!("Delete: input now: {:?}", self.input_text));
1012 }
1013 }
1014 KeyCode::Left => {
1015 if self.cursor_pos > 0 {
1016 self.cursor_pos = self.prev_char_pos();
1017 }
1018 }
1019 KeyCode::Right => {
1020 if self.cursor_pos < self.input_text.len() {
1021 self.cursor_pos = self.next_char_pos();
1022 }
1023 }
1024 KeyCode::Home => {
1025 self.cursor_pos = 0;
1026 }
1027 KeyCode::End => {
1028 self.cursor_pos = self.input_text.len();
1029 }
1030 KeyCode::Enter => {
1031 self.handle_enter()?;
1033 }
1034 KeyCode::Char(c)
1036 if key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT =>
1037 {
1038 if c == '@' {
1040 self.input_text.insert(self.cursor_pos, '@');
1042 self.cursor_pos += 1;
1043
1044 self.activate_file_autocomplete();
1046 } else {
1047 let is_autocomplete_active = self
1049 .file_autocomplete
1050 .as_ref()
1051 .map(|ac| ac.is_active)
1052 .unwrap_or(false);
1053
1054 if is_autocomplete_active {
1055 let query = self
1057 .file_autocomplete
1058 .as_ref()
1059 .map(|ac| {
1060 let mut q = ac.query.clone();
1061 q.push(c);
1062 q
1063 })
1064 .unwrap_or_default();
1065
1066 let matches = self.get_file_matches(&query);
1068
1069 if let Some(ref mut ac) = self.file_autocomplete {
1071 ac.query.push(c);
1072 ac.matches = matches;
1073 ac.selected_index = 0;
1074 }
1075
1076 self.input_text.insert(self.cursor_pos, c);
1078 self.cursor_pos += c.len_utf8();
1079 } else {
1080 self.input_text.insert(self.cursor_pos, c);
1082 self.cursor_pos += c.len_utf8();
1083 }
1084 }
1085 }
1086 _ => {
1087 }
1089 }
1090
1091 Ok(())
1092 }
1093
1094 fn handle_backspace(&mut self, key: &KeyEvent) -> bool {
1096 if key.code == KeyCode::Backspace {
1098 debug_log("Backspace detected via KeyCode::Backspace");
1099 self.delete_char_before_cursor();
1100 return true;
1101 }
1102
1103 if key.code == KeyCode::Char('h') && key.modifiers == KeyModifiers::CONTROL {
1105 debug_log("Backspace detected via Ctrl+H");
1106 self.delete_char_before_cursor();
1107 return true;
1108 }
1109
1110 if let KeyCode::Char(c) = key.code {
1112 if c == '\x7f' || c == '\x08' {
1113 debug_log(&format!("Backspace detected via char code: {}", c as u8));
1114 self.delete_char_before_cursor();
1115 return true;
1116 }
1117 }
1118
1119 false
1120 }
1121
1122 fn delete_char_before_cursor(&mut self) {
1123 debug_log(&format!(
1124 "delete_char: cursor={}, len={}, input={:?}",
1125 self.cursor_pos,
1126 self.input_text.len(),
1127 self.input_text
1128 ));
1129
1130 let should_close_autocomplete = if let Some(ref ac) = self.file_autocomplete {
1132 if ac.is_active {
1133 ac.query.is_empty()
1135 } else {
1136 false
1137 }
1138 } else {
1139 false
1140 };
1141
1142 if should_close_autocomplete {
1143 if self.cursor_pos > 0 {
1145 let prev_pos = self.prev_char_pos();
1146 if &self.input_text[prev_pos..self.cursor_pos] == "@" {
1147 self.input_text.drain(prev_pos..self.cursor_pos);
1148 self.cursor_pos = prev_pos;
1149 self.file_autocomplete = None;
1150 return;
1151 }
1152 }
1153 }
1154
1155 if self
1157 .file_autocomplete
1158 .as_ref()
1159 .map(|ac| ac.is_active && !ac.query.is_empty())
1160 .unwrap_or(false)
1161 {
1162 let new_query = self
1163 .file_autocomplete
1164 .as_ref()
1165 .map(|ac| {
1166 let mut q = ac.query.clone();
1167 q.pop();
1168 q
1169 })
1170 .unwrap_or_default();
1171
1172 let matches = self.get_file_matches(&new_query);
1173
1174 if let Some(ref mut ac) = self.file_autocomplete {
1175 ac.query.pop();
1176 ac.matches = matches;
1177 ac.selected_index = 0;
1178 }
1179 }
1180
1181 if self.cursor_pos > 0 {
1182 let prev_pos = self.prev_char_pos();
1183 debug_log(&format!("draining {}..{}", prev_pos, self.cursor_pos));
1184 self.input_text.drain(prev_pos..self.cursor_pos);
1185 self.cursor_pos = prev_pos;
1186 debug_log(&format!(
1187 "after delete: cursor={}, input={:?}",
1188 self.cursor_pos, self.input_text
1189 ));
1190 } else {
1191 debug_log("cursor at 0, nothing to delete");
1192 }
1193 }
1194
1195 fn activate_file_autocomplete(&mut self) {
1197 let matches = self.get_file_matches("");
1198
1199 self.file_autocomplete = Some(FileAutocompleteState {
1200 is_active: true,
1201 query: String::new(),
1202 trigger_pos: self.cursor_pos - 1, matches,
1204 selected_index: 0,
1205 });
1206
1207 debug_log(&format!(
1208 "Activated autocomplete at pos {}",
1209 self.cursor_pos - 1
1210 ));
1211 }
1212
1213 fn get_file_matches(&mut self, query: &str) -> Vec<FileMatchData> {
1215 let files = self.file_finder.scan_files().clone();
1216 let matches = self.file_finder.filter_files(&files, query);
1217
1218 matches
1219 .into_iter()
1220 .map(|m| FileMatchData {
1221 path: m.path.to_string_lossy().to_string(),
1222 is_dir: m.is_dir,
1223 })
1224 .collect()
1225 }
1226
1227 fn accept_file_completion(&mut self) {
1229 if let Some(ref ac) = self.file_autocomplete {
1230 if let Some(selected) = ac.matches.get(ac.selected_index) {
1231 let end_pos = self.cursor_pos;
1233
1234 let remove_start = ac.trigger_pos;
1236
1237 self.input_text.drain(remove_start + 1..end_pos);
1239 self.cursor_pos = remove_start + 1;
1240
1241 self.input_text.insert_str(self.cursor_pos, &selected.path);
1243 self.cursor_pos += selected.path.len();
1244
1245 self.input_text.insert(self.cursor_pos, ' ');
1247 self.cursor_pos += 1;
1248
1249 debug_log(&format!(
1250 "Accepted completion: {} -> input now: {:?}",
1251 selected.path, self.input_text
1252 ));
1253 }
1254 }
1255
1256 self.file_autocomplete = None;
1258 }
1259
1260 fn handle_enter(&mut self) -> Result<(), CliError> {
1261 let text = self.input_text.trim().to_string();
1262
1263 self.input_text.clear();
1265 self.cursor_pos = 0;
1266
1267 if text.is_empty() {
1268 return Ok(());
1269 }
1270
1271 tracing::info!("Enter pressed with text: {:?}", text);
1272
1273 let text_lower = text.to_lowercase();
1275 if text_lower == "/exit"
1276 || text_lower == "/quit"
1277 || text_lower == "exit"
1278 || text_lower == "quit"
1279 {
1280 tracing::info!("Exit command detected, exiting");
1281 self.running = false;
1282 return Ok(());
1283 }
1284
1285 if text_lower == "/clear" || text_lower == "clear" {
1286 tracing::info!("Clear command detected");
1287 self.tui_bridge.chat_view().lock().unwrap().clear();
1288 return Ok(());
1289 }
1290
1291 if text_lower == "/help" || text_lower == "help" {
1292 tracing::info!("Help command detected");
1293 let help_msg = Message::system(
1294 "Available commands:\n\
1295 /help - Show this help message\n\
1296 /clear - Clear chat history\n\
1297 /exit - Exit the application\n\
1298 /quit - Exit the application\n\
1299 /session list - List all sessions\n\
1300 /session new - Create a new session\n\
1301 /session load <id> - Load a session by ID\n\
1302 /share - Copy session to clipboard (markdown)\n\
1303 /share md - Export session as markdown file\n\
1304 /share json - Export session as JSON file\n\
1305 \n\
1306 Page Up/Down - Scroll chat history"
1307 .to_string(),
1308 );
1309 self.tui_bridge
1310 .chat_view()
1311 .lock()
1312 .unwrap()
1313 .add_message(help_msg);
1314 return Ok(());
1315 }
1316
1317 if text_lower.starts_with("/session ") {
1319 let session_cmd = text.strip_prefix("/session ").unwrap();
1320 if session_cmd.trim() == "list" {
1321 self.handle_session_list()?;
1322 return Ok(());
1323 } else if session_cmd.trim() == "new" {
1324 self.handle_session_new()?;
1325 return Ok(());
1326 } else if session_cmd.starts_with("load ") {
1327 let session_id = session_cmd.strip_prefix("load ").unwrap().trim();
1328 self.handle_session_load(session_id)?;
1329 return Ok(());
1330 } else {
1331 let error_msg = Message::system(
1332 "Usage: /session list, /session new, /session load <id>".to_string(),
1333 );
1334 self.tui_bridge
1335 .chat_view()
1336 .lock()
1337 .unwrap()
1338 .add_message(error_msg);
1339 return Ok(());
1340 }
1341 }
1342
1343 if text_lower == "/share" || text_lower.starts_with("/share ") {
1345 let share_cmd = text.strip_prefix("/share ").unwrap_or("").trim();
1346 self.handle_share(share_cmd)?;
1347 return Ok(());
1348 }
1349
1350 self.tui_bridge.add_user_message(text.clone());
1352
1353 let operation_id = self.tui_bridge.next_operation_id();
1355 debug_log(&format!("handle_enter: new operation_id={}", operation_id));
1356 *self.tui_bridge.state.lock().unwrap() = TuiState::Idle;
1357
1358 let cancel_token = tokio_util::sync::CancellationToken::new();
1360 self.cancellation_token = Some(cancel_token.clone());
1361
1362 let messages = self.tui_bridge.messages.clone();
1364 let agent_bridge = self.tui_bridge.agent_bridge_arc();
1365 let session_manager = self.tui_bridge.session_manager.clone();
1366 let session_id = self.tui_bridge.session_id();
1367 let total_input_tokens = self.tui_bridge.total_input_tokens.clone();
1368 let total_output_tokens = self.tui_bridge.total_output_tokens.clone();
1369
1370 tracing::debug!("Spawning LLM processing thread");
1371
1372 std::thread::spawn(move || {
1374 let rt = tokio::runtime::Runtime::new().unwrap();
1376
1377 #[allow(clippy::await_holding_lock)]
1379 rt.block_on(async {
1380 if cancel_token.is_cancelled() {
1382 tracing::debug!("Operation cancelled before acquiring locks");
1383 return;
1384 }
1385
1386 let messages_guard = {
1388 let mut attempts = 0;
1389 loop {
1390 if cancel_token.is_cancelled() {
1391 tracing::debug!("Operation cancelled while waiting for messages lock");
1392 return;
1393 }
1394 match messages.try_lock() {
1395 Ok(guard) => break guard,
1396 Err(std::sync::TryLockError::WouldBlock) => {
1397 attempts += 1;
1398 if attempts > 50 {
1399 tracing::error!("Timeout waiting for messages lock");
1400 return;
1401 }
1402 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1403 }
1404 Err(e) => {
1405 tracing::error!("Failed to lock messages: {}", e);
1406 return;
1407 }
1408 }
1409 }
1410 };
1411
1412 let mut messages_guard = messages_guard;
1413
1414 if cancel_token.is_cancelled() {
1416 tracing::debug!("Operation cancelled before acquiring bridge lock");
1417 return;
1418 }
1419
1420 let bridge_guard = {
1421 let mut attempts = 0;
1422 loop {
1423 if cancel_token.is_cancelled() {
1424 tracing::debug!("Operation cancelled while waiting for bridge lock");
1425 return;
1426 }
1427 match agent_bridge.try_lock() {
1428 Ok(guard) => break guard,
1429 Err(std::sync::TryLockError::WouldBlock) => {
1430 attempts += 1;
1431 if attempts > 50 {
1432 tracing::error!("Timeout waiting for bridge lock");
1433 return;
1434 }
1435 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1436 }
1437 Err(e) => {
1438 tracing::error!("Failed to lock agent_bridge: {}", e);
1439 return;
1440 }
1441 }
1442 }
1443 };
1444
1445 let mut bridge = bridge_guard;
1446
1447 bridge.set_cancellation_token(cancel_token.clone(), operation_id);
1449
1450 match bridge.process_message(&text, &mut messages_guard).await {
1451 Ok(_response) => {
1452 let msgs = messages_guard.clone();
1458 let input_tokens = *total_input_tokens.lock().unwrap();
1459 let output_tokens = *total_output_tokens.lock().unwrap();
1460
1461 if let Err(e) = session_manager.lock().unwrap().save_session(
1462 &session_id,
1463 &msgs,
1464 input_tokens,
1465 output_tokens,
1466 ) {
1467 tracing::error!("✗ Failed to auto-save session {}: {}", session_id, e);
1468 } else {
1469 tracing::info!(
1470 "✓ Session {} auto-saved ({} messages, {} in, {} out tokens)",
1471 session_id,
1472 msgs.len(),
1473 input_tokens,
1474 output_tokens
1475 );
1476 }
1477 }
1478 Err(e) => {
1479 let error_msg = e.to_string();
1481 if error_msg.contains("cancelled") {
1482 tracing::info!("Request cancelled by user");
1483 } else {
1484 tracing::error!("LLM error: {}", e);
1485 }
1486 }
1487 }
1488
1489 bridge.clear_cancellation_token();
1491 });
1492 });
1493
1494 Ok(())
1495 }
1496
1497 fn cancel_current_operation(&mut self) {
1499 if let Some(ref token) = self.cancellation_token {
1500 token.cancel();
1501 debug_log("Cancellation token triggered");
1502
1503 self.tui_bridge.next_operation_id();
1505
1506 *self.tui_bridge.state.lock().unwrap() = TuiState::Idle;
1508
1509 self.status_message = "Operation cancelled".to_string();
1511 self.status_is_error = false;
1512
1513 self.tui_bridge
1515 .activity_feed()
1516 .lock()
1517 .unwrap()
1518 .complete_all();
1519
1520 let cancel_msg = Message::system("⚠ Operation cancelled by user".to_string());
1522 self.tui_bridge
1523 .chat_view()
1524 .lock()
1525 .unwrap()
1526 .add_message(cancel_msg);
1527 }
1528 self.cancellation_token = None;
1529 self.last_esc_time = None;
1530 }
1531
1532 fn handle_session_list(&self) -> Result<(), CliError> {
1534 tracing::info!("Session list command detected");
1535 let session_manager = self.tui_bridge.session_manager.lock().unwrap();
1536 let current_session_id = self.tui_bridge.session_id();
1537
1538 match session_manager.list_sessions() {
1539 Ok(sessions) => {
1540 if sessions.is_empty() {
1541 let msg = Message::system("No sessions found.".to_string());
1542 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1543 } else {
1544 let mut output = vec!["Sessions (most recent first):".to_string()];
1545 for (i, session) in sessions.iter().enumerate() {
1546 let current = if session.id == current_session_id {
1547 " (current)"
1548 } else {
1549 ""
1550 };
1551 let short_id = if session.id.len() > 8 {
1552 &session.id[..8]
1553 } else {
1554 &session.id
1555 };
1556 output.push(format!(
1557 " {}. {}{} - {} messages, {} in tokens, {} out tokens",
1558 i + 1,
1559 short_id,
1560 current,
1561 session.message_count,
1562 session.total_input_tokens,
1563 session.total_output_tokens
1564 ));
1565 }
1566 let msg = Message::system(output.join("\n"));
1567 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1568 }
1569 }
1570 Err(e) => {
1571 let msg = Message::system(format!("Error listing sessions: {}", e));
1572 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1573 }
1574 }
1575 Ok(())
1576 }
1577
1578 fn handle_session_new(&mut self) -> Result<(), CliError> {
1580 tracing::info!("Session new command detected");
1581
1582 let save_result = self.tui_bridge.save_session();
1584 if let Err(e) = &save_result {
1585 tracing::error!("Failed to save current session: {}", e);
1586 let msg = Message::system(format!("⚠ Warning: Failed to save current session: {}", e));
1587 if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1588 chat.add_message(msg);
1589 }
1590 }
1591
1592 let new_session_id = {
1594 let session_manager = self.tui_bridge.session_manager.lock().map_err(|e| {
1595 CliError::ConfigError(format!("Failed to acquire session manager lock: {}", e))
1596 })?;
1597
1598 session_manager
1599 .create_new_session()
1600 .map_err(|e| CliError::ConfigError(format!("Failed to create session: {}", e)))?
1601 };
1602
1603 let old_session_id = self.tui_bridge.session_id();
1604
1605 if let Ok(mut id_guard) = self.tui_bridge.session_id.try_lock() {
1607 *id_guard = new_session_id.clone();
1608 }
1609
1610 if let Ok(mut messages_guard) = self.tui_bridge.messages.try_lock() {
1612 messages_guard.clear();
1613 }
1614 if let Ok(mut input_guard) = self.tui_bridge.total_input_tokens.try_lock() {
1615 *input_guard = 0;
1616 }
1617 if let Ok(mut output_guard) = self.tui_bridge.total_output_tokens.try_lock() {
1618 *output_guard = 0;
1619 }
1620
1621 if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1623 chat.clear();
1624 }
1625
1626 tracing::info!(
1627 "Created new session: {} (old: {})",
1628 new_session_id,
1629 old_session_id
1630 );
1631
1632 let session_short_id = if new_session_id.len() > 8 {
1634 &new_session_id[new_session_id.len().saturating_sub(8)..]
1635 } else {
1636 &new_session_id
1637 };
1638 let msg = Message::system(format!("🆕 New session created: {}", session_short_id));
1639
1640 if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1641 chat.add_message(msg);
1642 }
1643
1644 Ok(())
1645 }
1646
1647 fn handle_session_load(&mut self, session_id: &str) -> Result<(), CliError> {
1649 tracing::info!("Session load command detected for session: {}", session_id);
1650
1651 let save_result = self.tui_bridge.save_session();
1653 if let Err(e) = &save_result {
1654 tracing::error!("Failed to save current session: {}", e);
1655 let msg = Message::system(format!("⚠ Warning: Failed to save current session: {}", e));
1656 if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1657 chat.add_message(msg);
1658 }
1659 }
1660
1661 let (full_session_id, session_info, messages) = {
1663 let session_manager = self.tui_bridge.session_manager.lock().map_err(|e| {
1664 CliError::ConfigError(format!("Failed to acquire session manager lock: {}", e))
1665 })?;
1666
1667 let sessions = session_manager
1668 .list_sessions()
1669 .map_err(|e| CliError::ConfigError(format!("Failed to list sessions: {}", e)))?;
1670
1671 let matched_session = if session_id.len() >= 8 {
1672 sessions
1674 .iter()
1675 .find(|s| s.id == session_id)
1676 .or_else(|| sessions.iter().find(|s| s.id.starts_with(session_id)))
1678 } else {
1679 sessions.iter().find(|s| s.id.starts_with(session_id))
1681 };
1682
1683 match matched_session {
1684 Some(info) => {
1685 let full_id = info.id.clone();
1686 let msgs = session_manager.load_session(&full_id).map_err(|e| {
1688 CliError::ConfigError(format!("Failed to load session {}: {}", full_id, e))
1689 })?;
1690 (full_id, info.clone(), msgs)
1691 }
1692 None => {
1693 let msg = Message::system(format!("❌ Session not found: {}", session_id));
1694 if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1695 chat.add_message(msg);
1696 }
1697 return Ok(());
1698 }
1699 }
1700 };
1701
1702 if let Ok(mut id_guard) = self.tui_bridge.session_id.try_lock() {
1704 *id_guard = full_session_id.clone();
1705 }
1706 if let Ok(mut input_guard) = self.tui_bridge.total_input_tokens.try_lock() {
1707 *input_guard = session_info.total_input_tokens;
1708 }
1709 if let Ok(mut output_guard) = self.tui_bridge.total_output_tokens.try_lock() {
1710 *output_guard = session_info.total_output_tokens;
1711 }
1712
1713 if let Ok(mut messages_guard) = self.tui_bridge.messages.try_lock() {
1715 *messages_guard = messages.clone();
1716 }
1717
1718 if let Ok(mut chat) = self.tui_bridge.chat_view.try_lock() {
1720 chat.clear();
1721
1722 for msg in &messages {
1724 match msg.role {
1725 limit_llm::Role::User => {
1726 let content = msg.content.as_deref().unwrap_or("");
1727 let chat_msg = Message::user(content.to_string());
1728 chat.add_message(chat_msg);
1729 }
1730 limit_llm::Role::Assistant => {
1731 let content = msg.content.as_deref().unwrap_or("");
1732 let chat_msg = Message::assistant(content.to_string());
1733 chat.add_message(chat_msg);
1734 }
1735 _ => {}
1736 }
1737 }
1738
1739 tracing::info!(
1740 "Loaded session: {} ({} messages)",
1741 full_session_id,
1742 messages.len()
1743 );
1744
1745 let session_short_id = if full_session_id.len() > 8 {
1747 &full_session_id[full_session_id.len().saturating_sub(8)..]
1748 } else {
1749 &full_session_id
1750 };
1751 let msg = Message::system(format!(
1752 "📂 Loaded session: {} ({} messages, {} in tokens, {} out tokens)",
1753 session_short_id,
1754 messages.len(),
1755 session_info.total_input_tokens,
1756 session_info.total_output_tokens
1757 ));
1758 chat.add_message(msg);
1759 }
1760
1761 Ok(())
1762 }
1763
1764 fn handle_share(&mut self, format_str: &str) -> Result<(), CliError> {
1766 use crate::session_share::{ExportFormat, SessionShare};
1767
1768 tracing::info!("Share command detected with format: {:?}", format_str);
1769
1770 let format = match format_str.to_lowercase().as_str() {
1772 "" | "clipboard" | "cb" => ExportFormat::Markdown,
1773 "md" | "markdown" => ExportFormat::Markdown,
1774 "json" => ExportFormat::Json,
1775 _ => {
1776 let msg = Message::system(
1777 "Invalid format. Use: /share, /share md, /share json".to_string(),
1778 );
1779 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1780 return Ok(());
1781 }
1782 };
1783
1784 let session_id = self.tui_bridge.session_id();
1786 let messages = self
1787 .tui_bridge
1788 .messages
1789 .lock()
1790 .map(|guard| guard.clone())
1791 .unwrap_or_default();
1792 let total_input_tokens = self.tui_bridge.total_input_tokens();
1793 let total_output_tokens = self.tui_bridge.total_output_tokens();
1794 let model = self
1795 .tui_bridge
1796 .agent_bridge
1797 .lock()
1798 .ok()
1799 .map(|bridge| bridge.model().to_string());
1800
1801 let user_assistant_count = messages
1803 .iter()
1804 .filter(|m| matches!(m.role, limit_llm::Role::User | limit_llm::Role::Assistant))
1805 .count();
1806
1807 if user_assistant_count == 0 {
1808 let msg =
1809 Message::system("⚠ No messages to share. Start a conversation first.".to_string());
1810 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1811 return Ok(());
1812 }
1813
1814 if format_str.is_empty() || format_str == "clipboard" || format_str == "cb" {
1816 match SessionShare::generate_share_content(
1818 &session_id,
1819 &messages,
1820 total_input_tokens,
1821 total_output_tokens,
1822 model.clone(),
1823 format,
1824 ) {
1825 Ok(content) => {
1826 if let Some(ref clipboard) = self.clipboard {
1827 match clipboard.set_text(&content) {
1828 Ok(()) => {
1829 let short_id = &session_id[..session_id.len().min(8)];
1830 let msg = Message::system(format!(
1831 "✓ Session {} copied to clipboard ({} messages, {} tokens)",
1832 short_id,
1833 user_assistant_count,
1834 total_input_tokens + total_output_tokens
1835 ));
1836 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1837 }
1838 Err(e) => {
1839 let msg = Message::system(format!(
1840 "❌ Failed to copy to clipboard: {}",
1841 e
1842 ));
1843 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1844 }
1845 }
1846 } else {
1847 let msg = Message::system(
1848 "❌ Clipboard not available. Try '/share md' to save as file."
1849 .to_string(),
1850 );
1851 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1852 }
1853 }
1854 Err(e) => {
1855 let msg =
1856 Message::system(format!("❌ Failed to generate share content: {}", e));
1857 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1858 }
1859 }
1860 } else {
1861 match SessionShare::export_session(
1863 &session_id,
1864 &messages,
1865 total_input_tokens,
1866 total_output_tokens,
1867 model,
1868 format,
1869 ) {
1870 Ok((filepath, export)) => {
1871 let short_id = &session_id[..session_id.len().min(8)];
1872 let extension = match format {
1873 ExportFormat::Markdown => "md",
1874 ExportFormat::Json => "json",
1875 };
1876 let msg = Message::system(format!(
1877 "✓ Session {} exported to {}\n ({} messages, {} tokens)\n Location: ~/.limit/exports/",
1878 short_id,
1879 extension,
1880 user_assistant_count,
1881 total_input_tokens + total_output_tokens
1882 ));
1883 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1884
1885 tracing::info!(
1886 "Session exported to {:?} ({} messages)",
1887 filepath,
1888 export.messages.len()
1889 );
1890 }
1891 Err(e) => {
1892 let msg = Message::system(format!("❌ Failed to export session: {}", e));
1893 self.tui_bridge.chat_view().lock().unwrap().add_message(msg);
1894 }
1895 }
1896 }
1897
1898 Ok(())
1899 }
1900
1901 fn prev_char_pos(&self) -> usize {
1902 if self.cursor_pos == 0 {
1903 return 0;
1904 }
1905 let mut pos = self.cursor_pos - 1;
1907 while pos > 0 && !self.input_text.is_char_boundary(pos) {
1908 pos -= 1;
1909 }
1910 pos
1911 }
1912
1913 fn next_char_pos(&self) -> usize {
1914 if self.cursor_pos >= self.input_text.len() {
1915 return self.input_text.len();
1916 }
1917 let mut pos = self.cursor_pos + 1;
1919 while pos < self.input_text.len() && !self.input_text.is_char_boundary(pos) {
1920 pos += 1;
1921 }
1922 pos
1923 }
1924
1925 fn draw(&mut self) -> Result<(), CliError> {
1926 let chat_view = self.tui_bridge.chat_view().clone();
1927 let state = self.tui_bridge.state();
1928 let input_text = self.input_text.clone();
1929 let cursor_pos = self.cursor_pos;
1930 let status_message = self.status_message.clone();
1931 let status_is_error = self.status_is_error;
1932 let cursor_blink_state = self.cursor_blink_state;
1933 let tui_bridge = &self.tui_bridge;
1934 let file_autocomplete = self.file_autocomplete.clone();
1935
1936 self.terminal
1937 .draw(|f| {
1938 Self::draw_ui(
1939 f,
1940 &chat_view,
1941 state,
1942 &input_text,
1943 cursor_pos,
1944 &status_message,
1945 status_is_error,
1946 cursor_blink_state,
1947 tui_bridge,
1948 &file_autocomplete,
1949 );
1950 })
1951 .map_err(|e| CliError::IoError(io::Error::other(e)))?;
1952
1953 Ok(())
1954 }
1955
1956 #[allow(clippy::too_many_arguments)]
1958 fn draw_ui(
1959 f: &mut Frame,
1960 chat_view: &Arc<Mutex<ChatView>>,
1961 _state: TuiState,
1962 input_text: &str,
1963 cursor_pos: usize,
1964 status_message: &str,
1965 status_is_error: bool,
1966 cursor_blink_state: bool,
1967 tui_bridge: &TuiBridge,
1968 file_autocomplete: &Option<FileAutocompleteState>,
1969 ) {
1970 let size = f.area();
1971
1972 let activity_count = tui_bridge.activity_feed().lock().unwrap().len();
1974 let activity_height = if activity_count > 0 {
1975 (activity_count as u16).min(3) } else {
1977 0
1978 };
1979
1980 let constraints: Vec<Constraint> = vec![Constraint::Percentage(90)]; let mut constraints = constraints;
1983 if activity_height > 0 {
1984 constraints.push(Constraint::Length(activity_height)); }
1986 constraints.push(Constraint::Length(1)); constraints.push(Constraint::Length(6)); let chunks = Layout::default()
1991 .direction(Direction::Vertical)
1992 .constraints(constraints.as_slice())
1993 .split(size);
1994
1995 let mut chunk_idx = 0;
1996
1997 {
1999 let chat = chat_view.lock().unwrap();
2000 let total_input = tui_bridge.total_input_tokens();
2001 let total_output = tui_bridge.total_output_tokens();
2002 let title = format!(" Chat (↑{} ↓{}) ", total_input, total_output);
2003 let chat_block = Block::default()
2004 .borders(Borders::ALL)
2005 .title(title)
2006 .title_style(
2007 Style::default()
2008 .fg(Color::Cyan)
2009 .add_modifier(Modifier::BOLD),
2010 );
2011 f.render_widget(&*chat, chat_block.inner(chunks[chunk_idx]));
2012 f.render_widget(chat_block, chunks[chunk_idx]);
2013 chunk_idx += 1;
2014 }
2015
2016 if activity_height > 0 {
2018 let activity_feed = tui_bridge.activity_feed().lock().unwrap();
2019 let activity_block = Block::default()
2020 .borders(Borders::NONE)
2021 .style(Style::default().bg(Color::Reset));
2022 let activity_inner = activity_block.inner(chunks[chunk_idx]);
2023 f.render_widget(activity_block, chunks[chunk_idx]);
2024 activity_feed.render(activity_inner, f.buffer_mut());
2025 chunk_idx += 1;
2026 }
2027
2028 {
2030 let status_style = if status_is_error {
2031 Style::default().fg(Color::Red).bg(Color::Reset)
2032 } else {
2033 Style::default().fg(Color::Yellow)
2034 };
2035
2036 let status = Paragraph::new(Line::from(vec![
2037 Span::styled(" ● ", Style::default().fg(Color::Green)),
2038 Span::styled(status_message, status_style),
2039 ]));
2040 f.render_widget(status, chunks[chunk_idx]);
2041 chunk_idx += 1;
2042 }
2043
2044 {
2046 let input_block = Block::default()
2047 .borders(Borders::ALL)
2048 .title(" Input (Esc or /exit to quit) ")
2049 .title_style(Style::default().fg(Color::Cyan));
2050
2051 let input_inner = input_block.inner(chunks[chunk_idx]);
2052 f.render_widget(input_block, chunks[chunk_idx]);
2053
2054 let before_cursor = &input_text[..cursor_pos];
2056 let at_cursor = if cursor_pos < input_text.len() {
2057 &input_text[cursor_pos
2058 ..cursor_pos
2059 + input_text[cursor_pos..]
2060 .chars()
2061 .next()
2062 .map(|c| c.len_utf8())
2063 .unwrap_or(0)]
2064 } else {
2065 " "
2066 };
2067 let after_cursor = if cursor_pos < input_text.len() {
2068 &input_text[cursor_pos + at_cursor.len()..]
2069 } else {
2070 ""
2071 };
2072
2073 let cursor_style = if cursor_blink_state {
2074 Style::default().bg(Color::White).fg(Color::Black)
2075 } else {
2076 Style::default().bg(Color::Reset).fg(Color::Reset)
2077 };
2078
2079 let input_line = if input_text.is_empty() {
2080 Line::from(vec![Span::styled(
2081 "Type your message here...",
2082 Style::default().fg(Color::DarkGray),
2083 )])
2084 } else {
2085 Line::from(vec![
2086 Span::raw(before_cursor),
2087 Span::styled(at_cursor, cursor_style),
2088 Span::raw(after_cursor),
2089 ])
2090 };
2091
2092 let input_para = Paragraph::new(input_line).wrap(Wrap { trim: false });
2093 f.render_widget(input_para, input_inner);
2094 }
2095
2096 if let Some(ref ac) = file_autocomplete {
2098 if ac.is_active && !ac.matches.is_empty() {
2099 let input_area = chunks.last().unwrap();
2101
2102 let popup_area = calculate_popup_area(*input_area, ac.matches.len());
2104
2105 let widget = FileAutocompleteWidget::new(&ac.matches, ac.selected_index, &ac.query);
2107 f.render_widget(widget, popup_area);
2108 }
2109 }
2110 }
2111}
2112
2113#[cfg(test)]
2114mod tests {
2115 use super::*;
2116
2117 fn create_test_config() -> limit_llm::Config {
2119 use limit_llm::ProviderConfig;
2120 let mut providers = std::collections::HashMap::new();
2121 providers.insert(
2122 "anthropic".to_string(),
2123 ProviderConfig {
2124 api_key: Some("test-key".to_string()),
2125 model: "claude-3-5-sonnet-20241022".to_string(),
2126 base_url: None,
2127 max_tokens: 4096,
2128 timeout: 60,
2129 max_iterations: 100,
2130 thinking_enabled: false,
2131 clear_thinking: true,
2132 },
2133 );
2134 limit_llm::Config {
2135 provider: "anthropic".to_string(),
2136 providers,
2137 }
2138 }
2139
2140 #[test]
2141 fn test_tui_bridge_new() {
2142 let config = create_test_config();
2143 let agent_bridge = AgentBridge::new(config).unwrap();
2144 let (_tx, rx) = mpsc::unbounded_channel();
2145
2146 let tui_bridge = TuiBridge::new(agent_bridge, rx).unwrap();
2147 assert_eq!(tui_bridge.state(), TuiState::Idle);
2148 }
2149
2150 #[test]
2151 fn test_tui_bridge_state() {
2152 let config = create_test_config();
2153 let agent_bridge = AgentBridge::new(config).unwrap();
2154 let (tx, rx) = mpsc::unbounded_channel();
2155
2156 let mut tui_bridge = TuiBridge::new(agent_bridge, rx).unwrap();
2157
2158 let op_id = tui_bridge.operation_id();
2159 tx.send(AgentEvent::Thinking { operation_id: op_id }).unwrap();
2160 tui_bridge.process_events().unwrap();
2161 assert!(matches!(tui_bridge.state(), TuiState::Thinking));
2162
2163 tx.send(AgentEvent::Done { operation_id: op_id }).unwrap();
2164 tui_bridge.process_events().unwrap();
2165 assert_eq!(tui_bridge.state(), TuiState::Idle);
2166 }
2167
2168 #[test]
2169 fn test_tui_bridge_chat_view() {
2170 let config = create_test_config();
2171 let agent_bridge = AgentBridge::new(config).unwrap();
2172 let (_tx, rx) = mpsc::unbounded_channel();
2173
2174 let tui_bridge = TuiBridge::new(agent_bridge, rx).unwrap();
2175
2176 tui_bridge.add_user_message("Hello".to_string());
2177 assert_eq!(tui_bridge.chat_view().lock().unwrap().message_count(), 3); }
2179
2180 #[test]
2181 fn test_tui_state_default() {
2182 let state = TuiState::default();
2183 assert_eq!(state, TuiState::Idle);
2184 }
2185}