diff --git a/crates/g3-cli/src/retro_tui.rs b/crates/g3-cli/src/retro_tui.rs deleted file mode 100644 index 9c84172..0000000 --- a/crates/g3-cli/src/retro_tui.rs +++ /dev/null @@ -1,1567 +0,0 @@ -use anyhow::Result; -use crossterm::{ - event::{DisableMouseCapture, EnableMouseCapture}, - execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, -}; -use ratatui::{ - backend::CrosstermBackend, - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap}, - Frame, Terminal, -}; -use std::io; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; -use tokio::sync::mpsc; -use std::collections::VecDeque; - -use crate::theme::ColorTheme; - -// Color theme will be loaded dynamically - -// Scrolling configuration -const SCROLL_PAST_END_BUFFER: usize = 10; // Extra lines to allow scrolling past the end - -/// Message types for communication between threads -#[derive(Debug, Clone)] -pub enum TuiMessage { - AgentOutput(String), - ToolOutput { - name: String, - caption: String, - content: String, - }, - ToolDetailUpdate { - name: String, - content: String, - }, - ToolComplete { - name: String, - success: bool, - duration_ms: u128, - caption: String, - }, - SystemStatus(String), - ContextUpdate { - used: u32, - total: u32, - percentage: f32, - }, - SSEReceived, // New message type for SSE events (including pings) - Error(String), - Exit, -} - -/// Shared state for the retro terminal -struct TerminalState { - /// Color theme - theme: ColorTheme, - /// Current input buffer - input_buffer: String, - /// Cursor position in input buffer (for editing) - cursor_position: usize, - /// Output history - output_history: Vec, - /// Scroll position in output - scroll_offset: usize, - /// Cursor blink state - cursor_blink: bool, - /// Animation state for activity area (0.0 = hidden, 1.0 = fully shown) - activity_animation: f32, - /// Target animation state - activity_animation_target: f32, - /// Tool activity history (left side of activity box) - tool_activity: Vec, - /// Track if tool activity should auto-scroll - tool_activity_auto_scroll: bool, - /// Tool activity scroll offset - tool_activity_scroll: usize, - /// Last known visible height of output area - last_visible_height: usize, - /// User has manually scrolled (disable auto-scroll) - manual_scroll: bool, - /// Last cursor blink time - last_blink: Instant, - /// System status line - status_line: String, - /// Context window info - context_info: (u32, u32, f32), - /// Provider and model info - provider_info: (String, String), - /// Status blink state (for PROCESSING) - status_blink: bool, - /// Last status blink time - last_status_blink: Instant, - /// Whether we're in processing mode (for cursor display) - is_processing: bool, - /// Should exit - should_exit: bool, - /// Track the last tool header line index for updating it - last_tool_header_index: Option, - /// Token rate tracking for wave animation - token_wave_history: VecDeque, // Wave animation values for tokens - /// SSE rate tracking for wave animation - sse_wave_history: VecDeque, // Wave animation values for SSEs - /// Start time for token tracking - _session_start: Instant, // Prefixed with _ to indicate it's intentionally unused for now - /// SSE counter (including pings) - sse_count: u32, - /// Last token count for rate calculation - last_token_count: u32, -} - -impl TerminalState { - fn new(theme: ColorTheme) -> Self { - Self { - theme, - input_buffer: String::new(), - cursor_position: 0, - output_history: vec![ - "WEYLAND-YUTANI SYSTEMS".to_string(), - "MU/TH/UR 6000 - INTERFACE 2.4.1".to_string(), - "".to_string(), - "SYSTEM INITIALIZED".to_string(), - "AWAITING COMMAND...".to_string(), - "".to_string(), - ], - scroll_offset: 0, - cursor_blink: true, - activity_animation: 0.0, - activity_animation_target: 0.0, - tool_activity: Vec::new(), - tool_activity_auto_scroll: true, - tool_activity_scroll: 0, - last_visible_height: 0, // Will be set on first draw - manual_scroll: false, - last_blink: Instant::now(), - status_line: "READY".to_string(), - context_info: (0, 0, 0.0), - provider_info: ("UNKNOWN".to_string(), "UNKNOWN".to_string()), - status_blink: true, - last_status_blink: Instant::now(), - is_processing: false, - should_exit: false, - last_tool_header_index: None, - token_wave_history: VecDeque::with_capacity(40), // Keep 40 points for wave animation - sse_wave_history: VecDeque::with_capacity(40), // Keep 40 points for wave animation - _session_start: Instant::now(), - last_token_count: 0, - sse_count: 0, - } - } - - /// Format tool call output - fn format_tool_output(&mut self, tool_name: &str, caption: &str, content: &str) { - // Add tool header bar to main output - let header_text = format!(" {} | {}", tool_name.to_uppercase(), caption); - - // Add marker for special styling - self.output_history.push(format!("[TOOL_HEADER]{}", header_text)); - - // Track the index of this tool header for later updates - self.last_tool_header_index = Some(self.output_history.len() - 1); - - self.output_history.push(String::new()); // Empty line after header - - // Add the actual tool content to the tool detail panel - self.tool_activity.clear(); // Clear previous activity - self.tool_activity.push(format!("[{}] {}", tool_name.to_uppercase(), caption)); - self.tool_activity.push(String::new()); - for line in content.lines() { - self.tool_activity.push(line.to_string()); - } - - // Auto-scroll to bottom of tool activity if auto-scroll is enabled - if self.tool_activity_auto_scroll { - // Use the actual height of the tool detail area (8 lines total, minus 2 for borders = 6) - let visible_height = 6; - if self.tool_activity.len() > visible_height { - self.tool_activity_scroll = self.tool_activity.len().saturating_sub(visible_height); - } - } - - // Auto-scroll to bottom only if user hasn't manually scrolled - if !self.manual_scroll { - let total_lines = self.output_history.len(); - let visible_height = self.last_visible_height.max(1); - - // Calculate scroll to ensure ALL lines including the last are visible - if total_lines > visible_height { - // The problem: we want to show lines from scroll_offset to scroll_offset + visible_height - 1 - // To see the last line (at index total_lines - 1), we need: - // scroll_offset + visible_height - 1 >= total_lines - 1 - // scroll_offset >= total_lines - visible_height - // But we also need to ensure we're not cutting off content - // So we add 1 to ensure the last line is fully visible - self.scroll_offset = total_lines.saturating_sub(visible_height.saturating_sub(1)); - } else { - self.scroll_offset = 0; - } - } - } - - /// Update tool header with completion status and timing - fn update_tool_completion(&mut self, name: &str, success: bool, duration_ms: u128, caption: &str) { - // Find and update the last tool header in place - if let Some(index) = self.last_tool_header_index { - if index < self.output_history.len() { - // Format the timing info - let timing = if duration_ms < 1000 { - format!("{}ms", duration_ms) - } else { - format!("{:.2}s", duration_ms as f64 / 1000.0) - }; - - // Create the updated header with status marker and timing - let status_marker = if success { "[SUCCESS]" } else { "[FAILED]" }; - let header_text = format!(" {} | {} | {}", name.to_uppercase(), caption, timing); - - // Replace the existing header line with the updated one - self.output_history[index] = format!("{}{}", status_marker, header_text); - - // Clear the tracking index - self.last_tool_header_index = None; - } - } - } - - /// Update tool detail panel without changing the header - fn update_tool_detail(&mut self, name: &str, content: &str) { - // Update the tool detail panel with the complete content - self.tool_activity.clear(); - self.tool_activity.push(format!("[{}] Complete", name.to_uppercase())); - self.tool_activity.push(String::new()); - - // Add all the content lines - for line in content.lines() { - self.tool_activity.push(line.to_string()); - } - - // Auto-scroll to bottom of tool activity if auto-scroll is enabled - if self.tool_activity_auto_scroll { - let visible_height = 6; // Tool detail area is 8 lines minus 2 for borders - if self.tool_activity.len() > visible_height { - self.tool_activity_scroll = self.tool_activity.len().saturating_sub(visible_height); - } - } - } - - /// Parse markdown and convert to styled lines - fn parse_markdown_line(&self, line: &str) -> Line<'_> { - // Skip parsing for special status lines to preserve their formatting - if line.starts_with("[SUCCESS]") || - line.starts_with("[FAILED]") || - line.starts_with("[TOOL_HEADER]") { - // These should be handled elsewhere, but as a safety check - return Line::from(Span::styled( - format!(" {}", line), - Style::default().fg(self.theme.terminal_green.to_color()), - )); - } - - let mut spans = Vec::new(); - let mut chars = line.chars().peekable(); - let mut current_text = String::new(); - - // Check for headers first - if let Some(stripped) = line.strip_prefix("### ") { - return Line::from(Span::styled( - format!(" {}", stripped), - Style::default() - .fg(self.theme.terminal_cyan.to_color()) - .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), - )); - } else if let Some(stripped) = line.strip_prefix("## ") { - return Line::from(Span::styled( - format!(" {}", stripped), - Style::default() - .fg(self.theme.terminal_amber.to_color()) - .add_modifier(Modifier::BOLD), - )); - } else if let Some(stripped) = line.strip_prefix("# ") { - return Line::from(Span::styled( - format!(" {}", stripped), - Style::default() - .fg(self.theme.terminal_green.to_color()) - .add_modifier(Modifier::BOLD), - )); - } - - // Check for code block markers - if line.starts_with("```") { - return Line::from(Span::styled( - format!(" {}", line), - Style::default() - .fg(self.theme.terminal_dim_green.to_color()) - .bg(Color::Rgb(40, 42, 54)), // Dark background for code blocks - )); - } - - // Add leading space - spans.push(Span::raw(" ")); - - // Parse inline formatting - while let Some(ch) = chars.next() { - if ch == '*' { - // Check for bold (**) or italic (*) - if chars.peek() == Some(&'*') { - chars.next(); // consume second * - // Save current text - if !current_text.is_empty() { - spans.push(Span::styled( - current_text.clone(), - Style::default().fg(self.theme.terminal_green.to_color()), - )); - current_text.clear(); - } - // Find closing ** - let mut bold_text = String::new(); - while let Some(ch) = chars.next() { - if ch == '*' && chars.peek() == Some(&'*') { - chars.next(); // consume second * - break; - } - bold_text.push(ch); - } - spans.push(Span::styled( - bold_text, - Style::default() - .fg(self.theme.terminal_amber.to_color()) - .add_modifier(Modifier::BOLD), - )); - } else { - // Handle italic (*) - if !current_text.is_empty() { - spans.push(Span::styled( - current_text.clone(), - Style::default().fg(self.theme.terminal_green.to_color()), - )); - current_text.clear(); - } - // Find closing * - let mut italic_text = String::new(); - for ch in chars.by_ref() { - if ch == '*' { - break; - } - italic_text.push(ch); - } - spans.push(Span::styled( - italic_text, - Style::default() - .fg(self.theme.terminal_cyan.to_color()) - .add_modifier(Modifier::ITALIC), - )); - } - } else if ch == '`' { - // Handle inline code - if !current_text.is_empty() { - spans.push(Span::styled( - current_text.clone(), - Style::default().fg(self.theme.terminal_green.to_color()), - )); - current_text.clear(); - } - // Find closing ` - let mut code_text = String::new(); - for ch in chars.by_ref() { - if ch == '`' { - break; - } - code_text.push(ch); - } - spans.push(Span::styled( - code_text, - Style::default() - .fg(self.theme.terminal_cyan.to_color()) - .bg(Color::Rgb(40, 42, 54)), - )); - } else { - current_text.push(ch); - } - } - - // Add any remaining text - if !current_text.is_empty() { - spans.push(Span::styled( - current_text, - Style::default().fg(self.theme.terminal_green.to_color()), - )); - } - - // Return the line with all spans - if spans.len() > 1 { // More than just the leading space - Line::from(spans) - } else { - // Fallback to plain text if no formatting found - Line::from(Span::styled( - format!(" {}", line), - Style::default().fg(self.theme.terminal_green.to_color()), - )) - } - } - - /// Add text to output history - fn add_output(&mut self, text: &str) { - let mut lines = text.lines(); - - // Remove any existing cursor from the last line before adding new content - if let Some(last) = self.output_history.last_mut() { - if last.ends_with('█') { - last.pop(); - } - } - - // Handle the first line specially - if let Some(first_line) = lines.next() { - if let Some(last) = self.output_history.last_mut() { - // Append first fragment to the last element - last.push_str(first_line); - } else { - // No existing elements, just push the first line - self.output_history.push(first_line.to_string()); - } - } - - // Push the remaining lines individually - for line in lines { - self.output_history.push(line.to_string()); - } - - // Always add cursor at the end if we're in PROCESSING mode - if self.is_processing { - if let Some(last) = self.output_history.last_mut() { - // Add a solid cursor at the end of the last line - last.push('█'); - } - } - - // Update scroll state - // Auto-scroll to bottom only if user hasn't manually scrolled - if !self.manual_scroll { - let total_lines = self.output_history.len(); - let visible_height = self.last_visible_height.max(1); - - // Calculate scroll to ensure ALL lines including the last are visible - if total_lines > visible_height { - // The problem: we want to show lines from scroll_offset to scroll_offset + visible_height - 1 - // To see the last line (at index total_lines - 1), we need: - // scroll_offset + visible_height - 1 >= total_lines - 1 - // scroll_offset >= total_lines - visible_height - // But we also need to ensure we're not cutting off content - // So we add 1 to ensure the last line is fully visible - self.scroll_offset = total_lines.saturating_sub(visible_height.saturating_sub(1)); - } else { - self.scroll_offset = 0; - } - } - } -} - -/// Public interface for the retro terminal -#[derive(Clone)] -pub struct RetroTui { - tx: mpsc::UnboundedSender, - state: Arc>, - terminal: Arc>>>, -} - -impl RetroTui { - /// Create and start the retro terminal UI - pub async fn start(theme: ColorTheme) -> Result { - // Setup terminal - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; - let backend = CrosstermBackend::new(stdout); - let terminal = Terminal::new(backend)?; - - // Create message channel - let (tx, mut rx) = mpsc::unbounded_channel::(); - - let state = Arc::new(Mutex::new(TerminalState::new(theme))); - let terminal = Arc::new(Mutex::new(terminal)); - - // Clone for the background task - let state_clone = state.clone(); - let terminal_clone = terminal.clone(); - - // Spawn background task to handle messages and redraw - tokio::spawn(async move { - let mut last_draw = Instant::now(); - - loop { - // Check for messages - while let Ok(msg) = rx.try_recv() { - let mut state = state_clone.lock().unwrap(); - match msg { - TuiMessage::AgentOutput(text) => { - state.add_output(&text); - } - TuiMessage::ToolOutput { - name, - caption, - content, - } => { - state.format_tool_output(&name, &caption, &content); - } - TuiMessage::ToolDetailUpdate { - name, - content, - } => { - state.update_tool_detail(&name, &content); - } - TuiMessage::ToolComplete { - name, - success, - duration_ms, - caption, - } => { - state.update_tool_completion(&name, success, duration_ms, &caption); - } - TuiMessage::SystemStatus(status) => { - let was_processing = state.status_line == "PROCESSING"; - state.status_line = status; - state.is_processing = state.status_line == "PROCESSING"; - // Set animation target based on processing state - state.activity_animation_target = if state.is_processing { 1.0 } else { 0.0 }; - - // Clear input buffer when entering PROCESSING mode - if !was_processing && state.is_processing { - state.input_buffer.clear(); - state.cursor_position = 0; - } - - // Remove cursor when exiting PROCESSING mode - if was_processing && !state.is_processing { - if let Some(last) = state.output_history.last_mut() { - if last.ends_with('█') { - last.pop(); - } - } - state.manual_scroll = false; // Reset manual scroll - } else if !was_processing && state.is_processing { - // Add cursor when entering PROCESSING mode - if let Some(last) = state.output_history.last_mut() { - last.push('█'); - } - } - } - TuiMessage::ContextUpdate { - used, - total, - percentage, - } => { - state.context_info = (used, total, percentage); - - // Update token wave animation - let tokens_since_last = used.saturating_sub(state.last_token_count) as f64; - - // Add a wave point based on token rate (normalized 0-1) - let wave_value = (tokens_since_last / 100.0).min(1.0); // Normalize to 0-1 - state.token_wave_history.push_back(wave_value); - - // Keep only last 40 data points for smooth animation - while state.token_wave_history.len() > 40 { - state.token_wave_history.pop_front(); - } - - state.last_token_count = used; - } - TuiMessage::SSEReceived => { - state.sse_count += 1; - - // Add a pulse to the SSE wave animation - state.sse_wave_history.push_back(1.0); // Full pulse for each SSE - - // Decay older values for smooth animation - for i in 0..state.sse_wave_history.len().saturating_sub(1) { - if let Some(val) = state.sse_wave_history.get_mut(i) { - *val *= 0.85; // Decay factor - } - } - - while state.sse_wave_history.len() > 40 { - state.sse_wave_history.pop_front(); - } - } - TuiMessage::Error(err) => { - state.add_output(&format!("ERROR: {}", err)); - } - TuiMessage::Exit => { - state.should_exit = true; - break; - } - } - } - - // Check if we should exit - if state_clone.lock().unwrap().should_exit { - break; - } - - // Update cursor blink - { - let mut state = state_clone.lock().unwrap(); - if state.last_blink.elapsed() > Duration::from_millis(500) { - state.cursor_blink = !state.cursor_blink; - state.last_blink = Instant::now(); - } - - // Update status blink only if status is "PROCESSING" - if state.status_line == "PROCESSING" && state.last_status_blink.elapsed() > Duration::from_millis(500) { - state.status_blink = !state.status_blink; - state.last_status_blink = Instant::now(); - } - - // Update activity area animation - let animation_speed = 0.15; // Adjust for faster/slower animation - if (state.activity_animation - state.activity_animation_target).abs() > 0.01 { - // Smoothly interpolate towards target - state.activity_animation += (state.activity_animation_target - state.activity_animation) * animation_speed; - // Clamp to valid range - state.activity_animation = state.activity_animation.clamp(0.0, 1.0); - } else { - // Snap to target when close enough - state.activity_animation = state.activity_animation_target; - } - } - - // Redraw at ~60fps - if last_draw.elapsed() > Duration::from_millis(16) { - let mut state = state_clone.lock().unwrap(); - let mut term = terminal_clone.lock().unwrap(); - let _ = Self::draw(&mut term, &mut state); - last_draw = Instant::now(); - } - - // Small sleep to prevent busy waiting - tokio::time::sleep(Duration::from_millis(10)).await; - } - }); - - // Initial draw - { - let mut state = state.lock().unwrap(); - let mut term = terminal.lock().unwrap(); - Self::draw(&mut term, &mut state)?; - } - - Ok(Self { - tx, - state, - terminal, - }) - } - - /// Draw the terminal UI - fn draw( - terminal: &mut Terminal>, - state: &mut TerminalState, - ) -> Result<()> { - terminal.draw(|f| { - let size = f.area(); - - // Calculate activity area height based on animation (0 to 8) - let activity_height = (8.0 * state.activity_animation).round() as u16; - - // Create main layout - dynamically adjust based on whether activity area is shown - let chunks = if activity_height > 0 { - Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(5), // Header/input area - Constraint::Min(10), // Main output area (will be further split) - Constraint::Length(activity_height), // Activity area (animated) - Constraint::Length(1), // Status bar - ]) - .split(size) - } else { - // When activity area is hidden, give more space to output - Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(5), // Header/input area - Constraint::Min(10), // Main output area gets all remaining space - Constraint::Length(1), // Status bar - ]) - .split(size) - }; - - // IMPORTANT: Update the last known visible height BEFORE drawing - // This ensures auto-scroll calculations use the correct height - let old_height = state.last_visible_height; - // Calculate the actual visible height accounting for padding (2 lines) - let new_visible_height = chunks[1].height.saturating_sub(2) as usize; - - // Only update if we have a valid height - if new_visible_height > 0 { - state.last_visible_height = new_visible_height; - } - - // If the height changed and we're auto-scrolling, recalculate scroll position - if old_height != state.last_visible_height && !state.manual_scroll { - let total_lines = state.output_history.len(); - if total_lines > state.last_visible_height { - // Recalculate to show the bottom content - state.scroll_offset = total_lines.saturating_sub(state.last_visible_height); - } - } - - // Draw header/input area - Self::draw_input_area(f, chunks[0], &state.input_buffer, state.cursor_position, state.cursor_blink, state.is_processing, &state.theme); - - // Draw main output area - Self::draw_output_area(f, chunks[1], state, &state.output_history, state.scroll_offset, &state.theme); - - // Draw activity area only if it's visible (during animation or when shown) - if activity_height > 0 { - // Apply fade effect by adjusting opacity through color intensity - let opacity = state.activity_animation; - Self::draw_activity_area(f, chunks[2], state, opacity, &state.theme); - } - - // Draw status bar - use the last chunk which is either index 2 or 3 - let status_bar_chunk = if activity_height > 0 { - chunks[3] - } else { - chunks[2] - }; - Self::draw_status_bar( - f, - status_bar_chunk, - &state.status_line, - state.context_info, - &state.provider_info, - state.status_blink, - &state.theme, - ); - })?; - - Ok(()) - } - - /// Draw the input area with prompt - fn draw_input_area(f: &mut Frame, area: Rect, input_buffer: &str, cursor_position: usize, cursor_blink: bool, is_processing: bool, theme: &ColorTheme) { - let prompt = "g3> "; - let prompt_len = prompt.len(); - - // Calculate available width for text (accounting for borders and prompt) - let available_width = area.width.saturating_sub(2).saturating_sub(prompt_len as u16) as usize; - - // Don't show cursor if processing - let show_cursor = !is_processing && cursor_blink; - - // Build the display text with cursor at the right position - let mut display_text = String::new(); - display_text.push_str(prompt); - - if input_buffer.is_empty() { - // Empty buffer - just show cursor if applicable - if show_cursor { - display_text.push('█'); - } - } else { - // Calculate which part of the buffer to show (handle wrapping) - let total_cursor_pos = cursor_position; - - // Determine the window into the buffer we should show - let window_start = total_cursor_pos.saturating_sub(available_width - 1); - - // Get the visible portion of the buffer - let visible_buffer: String = input_buffer - .chars() - .skip(window_start) - .take(available_width) - .collect(); - - // Insert cursor at the appropriate position in the visible text - let visible_cursor_pos = cursor_position.saturating_sub(window_start); - - for (i, ch) in visible_buffer.chars().enumerate() { - if i == visible_cursor_pos && show_cursor { - display_text.push('█'); - // Don't add the character under the cursor if we're showing the block cursor - } else { - display_text.push(ch); - } - } - - // If cursor is at the end and we're showing it - if visible_cursor_pos == visible_buffer.len() && show_cursor { - display_text.push('█'); - } - } - - let input = Paragraph::new(display_text) - .style(Style::default().fg(theme.terminal_green.to_color())) - .block( - Block::default() - .title(" COMMAND INPUT ") - .title_alignment(Alignment::Center) - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.terminal_dim_green.to_color())) - .style(Style::default().bg(theme.terminal_bg.to_color())), - ); - - f.render_widget(input, area); - } - - /// Draw the main output area - fn draw_output_area( - f: &mut Frame, - area: Rect, - state: &TerminalState, - output_history: &[String], - scroll_offset: usize, - theme: &ColorTheme, - ) { - // Calculate visible lines (no borders now, but padding takes 2 lines) - let visible_height = area.height.saturating_sub(2) as usize; // Account for padding - let total_lines = output_history.len(); - - // Calculate the proper scroll position - let scroll = if total_lines <= visible_height { - // If all content fits, no scrolling needed - 0 - } else { - // Allow scrolling SCROLL_PAST_END_BUFFER lines past the normal end - // This provides a buffer to ensure no content is cut off - let max_scroll_with_buffer = total_lines.saturating_sub(visible_height).saturating_add(SCROLL_PAST_END_BUFFER); - - // If the requested scroll would show past the end, adjust it - if scroll_offset > max_scroll_with_buffer { - max_scroll_with_buffer - } else { - scroll_offset - } - }; - - let mut in_code_block = false; - - // Get visible lines - let visible_lines: Vec = output_history - .iter() - .skip(scroll) - .take(visible_height) - .map(|line| { - // Check if this is a tool header line - if line.starts_with("[TOOL_HEADER]") { - // Extract the actual header text - let cleaned = line.replace("[TOOL_HEADER]", ""); - // Style with amber background and black text - return Line::from(Span::styled( - format!(" {}", cleaned), - Style::default() - .bg(theme.terminal_amber.to_color()) - .fg(Color::Black) - .add_modifier(Modifier::BOLD), - )); - } else if line.starts_with("[SUCCESS]") { - // Extract the actual header text - let cleaned = line.replace("[SUCCESS]", ""); - // Style with green background for successful tool completion - return Line::from(Span::styled( - format!(" {}", cleaned), - Style::default() - .bg(theme.terminal_success.to_color()) // Use dedicated success color - .fg(Color::Black) - .add_modifier(Modifier::BOLD), - )); - } else if line.starts_with("[FAILED]") { - // Extract the actual header text - let cleaned = line.replace("[FAILED]", ""); - // Style with red background for failed tool completion - return Line::from(Span::styled( - format!(" {}", cleaned), - Style::default() - .bg(theme.terminal_red.to_color()) - .fg(Color::Black) - .add_modifier(Modifier::BOLD), - )); - } - - // Check for code block boundaries - if line.starts_with("```") { - in_code_block = !in_code_block; - } - - // If we're in a code block, style it appropriately - if in_code_block && !line.starts_with("```") { - return Line::from(Span::styled( - format!(" {}", line), - Style::default() - .fg(theme.terminal_cyan.to_color()) - .bg(Color::Rgb(40, 42, 54)), - )); - } - - // Check if this is a box border line - if line.starts_with("┌") - || line.starts_with("└") - || line.starts_with("│") - || line.starts_with("├") - { - return Line::from(Span::styled( - format!(" {}", line), - Style::default().fg(theme.terminal_dim_green.to_color()), - )); - } - - // Don't apply markdown parsing to tool status lines - preserve their original styling - if line.starts_with("[SUCCESS]") || line.starts_with("[FAILED]") || line.starts_with("[TOOL_HEADER]") { - // These are already handled above, this shouldn't be reached - // but just in case, return the line as-is with appropriate color - return Line::from(Span::styled( - format!(" {}", line), - Style::default().fg(theme.terminal_green.to_color()), - )); - } - - // Check if line contains markdown formatting - if line.contains("**") || line.contains('`') || line.starts_with('#') { - // Use the markdown parser - return state.parse_markdown_line(line); - } - - // Apply different colors based on content (existing logic) - let style = if line.starts_with("ERROR:") { - Style::default() - .fg(theme.terminal_red.to_color()) - .add_modifier(Modifier::BOLD) - } else if line.starts_with('>') { - Style::default().fg(theme.terminal_cyan.to_color()) - } else if line.starts_with("SYSTEM:") - || line.starts_with("WEYLAND") - || line.starts_with("MU/TH/UR") - { - Style::default() - .fg(theme.terminal_amber.to_color()) - .add_modifier(Modifier::BOLD) - } else if line.starts_with("SYSTEM INITIALIZED") - || line.starts_with("AWAITING COMMAND") - { - Style::default() - .fg(theme.terminal_dim_green.to_color()) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(theme.terminal_green.to_color()) - }; - - Line::from(Span::styled(format!(" {}", line), style)) - }) - .collect(); - - let output = Paragraph::new(visible_lines) - .block( - Block::default() - // Remove borders but keep the block for spacing - .borders(Borders::NONE) - // Add padding to maintain the same spacing as borders would provide - .padding(ratatui::widgets::Padding::new(1, 1, 1, 1)) - .style(Style::default().bg(theme.terminal_bg.to_color())), - ) - .wrap(Wrap { trim: false }); - - f.render_widget(output, area); - - // Draw scrollbar if needed - if total_lines > visible_height { - let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) - .begin_symbol(Some("▲")) - .end_symbol(Some("▼")) - .track_symbol(Some("│")) - .thumb_symbol("█") - .style(Style::default().fg(theme.terminal_dim_green.to_color())); - - let mut scrollbar_state = ScrollbarState::new(total_lines) - .position(scroll) - .viewport_content_length(visible_height); - - f.render_stateful_widget( - scrollbar, - area.inner(ratatui::layout::Margin { - vertical: 0, // No borders, so no vertical margin needed - horizontal: 0, // Keep horizontal margin at 0 - }), - &mut scrollbar_state, - ); - } - } - - /// Draw the activity area with tool output - fn draw_activity_area( - f: &mut Frame, - area: Rect, - state: &TerminalState, - opacity: f32, - theme: &ColorTheme, - ) { - // Note: scroll_offset is managed by the state and auto-scrolls to show latest content when new data arrives - - // Apply fade effect by adjusting colors based on opacity - let fade_color = |color: Color| -> Color { - match color { - Color::Rgb(r, g, b) => { - let faded_r = (r as f32 * opacity) as u8; - let faded_g = (g as f32 * opacity) as u8; - let faded_b = (b as f32 * opacity) as u8; - Color::Rgb(faded_r, faded_g, faded_b) - } - _ => color, - } - }; - - // Split the activity area into left and right halves - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(50), // Left half for tool output - Constraint::Percentage(50), // Right half (reserved for future use) - ]) - .split(area); - - // Draw left half - Tool Activity - // Calculate actual visible height accounting for borders - let visible_height = chunks[0].height.saturating_sub(2).max(1) as usize; - let total_lines = state.tool_activity.len(); - let scroll_offset = state.tool_activity_scroll; - // Calculate scroll position - let scroll = if total_lines <= visible_height { - 0 - } else { - scroll_offset.min(total_lines.saturating_sub(visible_height)) - }; - - // Get visible lines for tool activity - let visible_lines: Vec = if state.tool_activity.is_empty() { - vec![Line::from(Span::styled( - " No tool activity yet", - Style::default().fg(fade_color(theme.terminal_dim_green.to_color())).add_modifier(Modifier::ITALIC), - ))] - } else { - state.tool_activity - .iter() - .skip(scroll) - .take(visible_height) - .map(|line| { - // Style the header lines differently - let style = if line.starts_with('[') && line.contains(']') { - Style::default().fg(fade_color(theme.terminal_cyan.to_color())).add_modifier(Modifier::BOLD) - } else if line.is_empty() { - Style::default() - } else { - Style::default().fg(fade_color(theme.terminal_green.to_color())) - }; - Line::from(Span::styled(format!(" {}", line), style)) - }) - .collect() - }; - - let tool_output = Paragraph::new(visible_lines) - .block( - Block::default() - .title(" TOOL DETAIL ") - .title_alignment(Alignment::Center) - .borders(Borders::ALL) - .border_style(Style::default().fg(fade_color(theme.terminal_dim_green.to_color()))) - .style(Style::default().bg(theme.terminal_bg.to_color())), - ) - .wrap(Wrap { trim: false }); - - f.render_widget(tool_output, chunks[0]); - - // Draw right half - Activity graphs with wave animations - Self::draw_activity_graphs(f, chunks[1], &state.token_wave_history, &state.sse_wave_history, opacity, theme); - } - - /// Draw activity graphs with wave animations for tokens and SSEs - fn draw_activity_graphs( - f: &mut Frame, - area: Rect, - token_wave: &VecDeque, - sse_wave: &VecDeque, - opacity: f32, - theme: &ColorTheme, - ) { - // Apply fade effect by adjusting colors based on opacity - let fade_color = |color: Color| -> Color { - match color { - Color::Rgb(r, g, b) => { - let faded_r = (r as f32 * opacity) as u8; - let faded_g = (g as f32 * opacity) as u8; - let faded_b = (b as f32 * opacity) as u8; - Color::Rgb(faded_r, faded_g, faded_b) - } - _ => color, - } - }; - - // Create the chart block - let block = Block::default() - .title(" ACTIVITY ") - .title_alignment(Alignment::Center) - .borders(Borders::ALL) - .border_style(Style::default().fg(fade_color(theme.terminal_dim_green.to_color()))) - .style(Style::default().bg(theme.terminal_bg.to_color())); - - // Calculate inner area for chart - let inner = block.inner(area); - - // Render the block first - f.render_widget(block, area); - - // If area too small, don't render graphs - if inner.width < 10 || inner.height < 4 { - return; - } - - // Split the inner area into two graphs (top and bottom) - let graph_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage(50), // Top graph for tokens - Constraint::Percentage(50), // Bottom graph for SSEs - ]) - .split(inner); - - // Draw token wave graph (top) - Self::draw_wave_graph( - f, - graph_chunks[0], - token_wave, - "TOKENS", - fade_color(theme.terminal_cyan.to_color()), - fade_color(theme.terminal_dim_green.to_color()), - opacity, - ); - - // Draw SSE wave graph (bottom) - Self::draw_wave_graph( - f, - graph_chunks[1], - sse_wave, - "SSE", - fade_color(theme.terminal_green.to_color()), - fade_color(theme.terminal_dim_green.to_color()), - opacity, - ); - } - - /// Draw a single wave animation graph - fn draw_wave_graph( - f: &mut Frame, - area: Rect, - wave_data: &VecDeque, - label: &str, - wave_color: Color, - _axis_color: Color, - _opacity: f32, - ) { - let width = area.width as usize; - let height = area.height as usize; - - if height < 2 || width < 5 { - return; - } - - // Wave characters for smooth animation - let wave_chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; - - // Build the wave line - let mut wave_line = String::new(); - wave_line.push_str(&format!("{:<6}", label)); // Left-aligned label - - // Calculate how many data points to show - let display_width = width.saturating_sub(6); // Account for label - - // Generate wave visualization - for i in 0..display_width { - let idx = wave_data.len().saturating_sub(display_width) + i; - - if idx < wave_data.len() { - let value = wave_data[idx].clamp(0.0, 1.0); - let char_idx = ((value * 7.0) as usize).min(7); - wave_line.push(wave_chars[char_idx]); - } else { - wave_line.push(wave_chars[0]); // Baseline - } - } - - // Create the wave line with color - let wave_paragraph = Paragraph::new(vec![ - Line::from(Span::styled(wave_line, Style::default().fg(wave_color))), - ]); - - f.render_widget(wave_paragraph, area); - } - - /// Draw the status bar - fn draw_status_bar( - f: &mut Frame, - area: Rect, - status_line: &str, - context_info: (u32, u32, f32), - provider_info: &(String, String), - status_blink: bool, - theme: &ColorTheme, - ) { - let (used, total, percentage) = context_info; - - // Create context meter - let bar_width = 10; - let filled = ((percentage / 100.0) * bar_width as f32) as usize; - let meter = format!("[{}{}]", "█".repeat(filled), "░".repeat(bar_width - filled)); - - let (_, model) = provider_info; - - // Determine status color based on status text - let (status_color, status_text) = if status_line == "PROCESSING" { - // Blink the PROCESSING status - if status_blink { - (theme.terminal_dark_amber.to_color(), status_line) - } else { - (theme.terminal_bg.to_color(), " ") // Hide text by matching background - } - } else if status_line == "READY" { - (theme.terminal_pale_blue.to_color(), status_line) - } else { - // Default to amber for other statuses - (theme.terminal_amber.to_color(), status_line) - }; - - // Build the status line with different colored spans - let status_spans = vec![ - Span::styled( - " STATUS: ", - Style::default() - .fg(theme.terminal_amber.to_color()) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - status_text, - Style::default() - .fg(status_color) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - " | CONTEXT: ", - Style::default() - .fg(theme.terminal_amber.to_color()) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!("{} {:.1}% ({}/{})", meter, percentage, used, total), - Style::default() - .fg(theme.terminal_amber.to_color()) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - " | ", - Style::default() - .fg(theme.terminal_amber.to_color()) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!("{} ", model), - Style::default() - .fg(theme.terminal_amber.to_color()) - .add_modifier(Modifier::BOLD), - ), - ]; - - let status_line = Line::from(status_spans); - - let status = Paragraph::new(status_line) - .style(Style::default().bg(theme.terminal_bg.to_color())) - .alignment(Alignment::Left); - - f.render_widget(status, area); - } - - /// Send output to the terminal - pub fn output(&self, text: &str) { - let _ = self.tx.send(TuiMessage::AgentOutput(text.to_string())); - } - - /// Send tool output to the terminal - pub fn tool_output(&self, name: &str, caption: &str, content: &str) { - let _ = self.tx.send(TuiMessage::ToolOutput { - name: name.to_string(), - caption: caption.to_string(), - content: content.to_string(), - }); - } - - /// Update tool detail panel without changing the header - pub fn update_tool_detail(&self, name: &str, content: &str) { - let _ = self.tx.send(TuiMessage::ToolDetailUpdate { - name: name.to_string(), - content: content.to_string(), - }); - } - - /// Send tool completion status to the terminal - pub fn tool_complete(&self, name: &str, success: bool, duration_ms: u128, caption: &str) { - let _ = self.tx.send(TuiMessage::ToolComplete { - name: name.to_string(), - success, - duration_ms, - caption: caption.to_string(), - }); - } - - /// Update system status - pub fn status(&self, status: &str) { - let _ = self.tx.send(TuiMessage::SystemStatus(status.to_string())); - } - - /// Update context window information - pub fn update_context(&self, used: u32, total: u32, percentage: f32) { - let _ = self.tx.send(TuiMessage::ContextUpdate { - used, - total, - percentage, - }); - } - - /// Update provider and model info - pub fn update_provider_info(&self, provider: &str, model: &str) { - if let Ok(mut state) = self.state.lock() { - state.provider_info = (provider.to_string(), model.to_string()); - } - } - - /// Notify that an SSE was received (including pings) - pub fn sse_received(&self) { - let _ = self.tx.send(TuiMessage::SSEReceived); - } - - /// Send error message - pub fn error(&self, error: &str) { - let _ = self.tx.send(TuiMessage::Error(error.to_string())); - } - - /// Signal exit - pub fn exit(&self) { - let _ = self.tx.send(TuiMessage::Exit); - } - - /// Update input buffer (for display) - pub fn update_input(&self, input: &str) { - if let Ok(mut state) = self.state.lock() { - state.input_buffer = input.to_string(); - // Keep cursor at end when updating the whole buffer - state.cursor_position = input.len(); - } - } - - /// Move cursor left - pub fn cursor_left(&self) { - if let Ok(mut state) = self.state.lock() { - if state.cursor_position > 0 { - state.cursor_position -= 1; - } - } - } - - /// Move cursor right - pub fn cursor_right(&self) { - if let Ok(mut state) = self.state.lock() { - if state.cursor_position < state.input_buffer.len() { - state.cursor_position += 1; - } - } - } - - /// Move cursor to beginning of line (Ctrl-A) - pub fn cursor_home(&self) { - if let Ok(mut state) = self.state.lock() { - state.cursor_position = 0; - } - } - - /// Move cursor to end of line (Ctrl-E) - pub fn cursor_end(&self) { - if let Ok(mut state) = self.state.lock() { - state.cursor_position = state.input_buffer.len(); - } - } - - /// Delete word before cursor (Ctrl-W) - pub fn delete_word(&self) { - if let Ok(mut state) = self.state.lock() { - if state.cursor_position > 0 { - // Find the start of the word to delete - let mut word_start = state.cursor_position; - let chars: Vec = state.input_buffer.chars().collect(); - - // Skip trailing spaces - while word_start > 0 && chars[word_start - 1].is_whitespace() { - word_start -= 1; - } - - // Find word boundary - while word_start > 0 && !chars[word_start - 1].is_whitespace() { - word_start -= 1; - } - - // Remove the word - let before = state.input_buffer.chars().take(word_start).collect::(); - let after = state.input_buffer.chars().skip(state.cursor_position).collect::(); - state.input_buffer = format!("{}{}", before, after); - state.cursor_position = word_start; - } - } - } - - /// Delete from cursor to end of line (Ctrl-K) - pub fn delete_to_end(&self) { - if let Ok(mut state) = self.state.lock() { - state.input_buffer = state.input_buffer.chars().take(state.cursor_position).collect(); - } - } - - /// Get current input buffer and cursor position - pub fn get_input_state(&self) -> (String, usize) { - if let Ok(state) = self.state.lock() { - (state.input_buffer.clone(), state.cursor_position) - } else { - (String::new(), 0) - } - } - - /// Insert character at cursor position - pub fn insert_char(&self, ch: char) { - if let Ok(mut state) = self.state.lock() { - let before = state.input_buffer.chars().take(state.cursor_position).collect::(); - let after = state.input_buffer.chars().skip(state.cursor_position).collect::(); - state.input_buffer = format!("{}{}{}", before, ch, after); - state.cursor_position += 1; - } - } - - /// Delete character at cursor position (Delete key) - pub fn delete_char(&self) { - if let Ok(mut state) = self.state.lock() { - if state.cursor_position < state.input_buffer.len() { - let before = state.input_buffer.chars().take(state.cursor_position).collect::(); - let after = state.input_buffer.chars().skip(state.cursor_position + 1).collect::(); - state.input_buffer = format!("{}{}", before, after); - } - } - } - - /// Delete character before cursor (Backspace) - pub fn backspace(&self) { - if let Ok(mut state) = self.state.lock() { - if state.cursor_position > 0 { - let before = state.input_buffer.chars().take(state.cursor_position - 1).collect::(); - let after = state.input_buffer.chars().skip(state.cursor_position).collect::(); - state.input_buffer = format!("{}{}", before, after); - state.cursor_position -= 1; - } - } - } - - /// Handle scrolling - pub fn scroll_up(&self) { - if let Ok(mut state) = self.state.lock() { - if state.scroll_offset > 0 { - state.manual_scroll = true; - state.scroll_offset -= 1; - } - } - } - - pub fn scroll_down(&self) { - if let Ok(mut state) = self.state.lock() { - state.manual_scroll = true; - let total_lines = state.output_history.len(); - let visible_height = state.last_visible_height.max(1); - - // Calculate max scroll position - // Allow scrolling SCROLL_PAST_END_BUFFER lines past what would normally be the end - // This gives some buffer space at the bottom - let max_scroll = total_lines.saturating_sub(visible_height).saturating_add(SCROLL_PAST_END_BUFFER); - - state.scroll_offset = (state.scroll_offset + 1).min(max_scroll); - } - } - - pub fn scroll_page_up(&self) { - if let Ok(mut state) = self.state.lock() { - state.manual_scroll = true; - // Use the last known visible height, or a reasonable default - // The actual visible area is typically around 20-30 lines minus borders - let page_size = if state.last_visible_height > 0 { - state.last_visible_height.saturating_sub(2) // Leave a couple lines for context - } else { - 15 // Reasonable default - }; - - if state.scroll_offset > 0 { - // Scroll up by a page worth of lines - state.scroll_offset = state.scroll_offset.saturating_sub(page_size); - } - } - } - - pub fn scroll_page_down(&self) { - if let Ok(mut state) = self.state.lock() { - state.manual_scroll = true; - let total_lines = state.output_history.len(); - let visible_height = state.last_visible_height.max(1); - - let page_size = if state.last_visible_height > 0 { - state.last_visible_height.saturating_sub(2) // Leave a couple lines for context - } else { - 15 // Reasonable default - }; - - // Calculate max scroll position - // Allow scrolling SCROLL_PAST_END_BUFFER lines past what would normally be the end - let max_scroll = total_lines.saturating_sub(visible_height).saturating_add(SCROLL_PAST_END_BUFFER); - - // Scroll down by a page, but don't go past the end - state.scroll_offset = (state.scroll_offset + page_size).min(max_scroll); - } - } - - pub fn scroll_home(&self) { - if let Ok(mut state) = self.state.lock() { - state.scroll_offset = 0; - } - } - - pub fn scroll_end(&self) { - if let Ok(mut state) = self.state.lock() { - let total_lines = state.output_history.len(); - let visible_height = state.last_visible_height.max(1); - - // Scroll to show the last page of content plus SCROLL_PAST_END_BUFFER extra lines - // This ensures we can see past the end a bit for safety - state.scroll_offset = total_lines.saturating_sub(visible_height).saturating_add(SCROLL_PAST_END_BUFFER); - - // When scrolling to end, disable manual scroll so auto-scroll resumes - state.manual_scroll = false; - } - } -} - -impl Drop for RetroTui { - fn drop(&mut self) { - // Restore terminal - let _ = disable_raw_mode(); - if let Ok(mut term) = self.terminal.lock() { - let _ = execute!( - term.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - ); - } - } -} diff --git a/crates/g3-cli/src/tui.rs b/crates/g3-cli/src/tui.rs deleted file mode 100644 index 95e2996..0000000 --- a/crates/g3-cli/src/tui.rs +++ /dev/null @@ -1,160 +0,0 @@ -use crossterm::style::Color; -use crossterm::style::{SetForegroundColor, ResetColor}; -use std::io::{self, Write}; -use termimad::MadSkin; - -/// Simple output handler with markdown support -pub struct SimpleOutput { - mad_skin: MadSkin, -} - -impl SimpleOutput { - pub fn new() -> Self { - let mut mad_skin = MadSkin::default(); - // Dracula color scheme - // Background: #282a36, Foreground: #f8f8f2 - // Colors: Cyan #8be9fd, Green #50fa7b, Orange #ffb86c, Pink #ff79c6, Purple #bd93f9, Red #ff5555, Yellow #f1fa8c - - mad_skin.set_headers_fg(Color::Rgb { r: 189, g: 147, b: 249 }); // Purple for headers - mad_skin.bold.set_fg(Color::Rgb { r: 255, g: 121, b: 198 }); // Pink for bold - mad_skin.italic.set_fg(Color::Rgb { r: 139, g: 233, b: 253 }); // Cyan for italic - mad_skin.code_block.set_bg(Color::Rgb { r: 68, g: 71, b: 90 }); // Dracula background variant - mad_skin.code_block.set_fg(Color::Rgb { r: 80, g: 250, b: 123 }); // Green for code text - mad_skin.inline_code.set_bg(Color::Rgb { r: 68, g: 71, b: 90 }); // Same background for inline code - mad_skin.inline_code.set_fg(Color::Rgb { r: 241, g: 250, b: 140 }); // Yellow for inline code - mad_skin.quote_mark.set_fg(Color::Rgb { r: 98, g: 114, b: 164 }); // Comment purple for quote marks - mad_skin.strikeout.set_fg(Color::Rgb { r: 255, g: 85, b: 85 }); // Red for strikethrough - - Self { mad_skin } - } - - /// Detect if text contains markdown formatting - fn has_markdown(&self, text: &str) -> bool { - // Check for common markdown patterns - text.contains("**") || - text.contains("```") || - text.contains("`") || - text.lines().any(|line| { - let trimmed = line.trim(); - trimmed.starts_with('#') || - trimmed.starts_with("- ") || - trimmed.starts_with("* ") || - trimmed.starts_with("+ ") || - (trimmed.len() > 2 && - trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) && - trimmed.chars().nth(1) == Some('.') && - trimmed.chars().nth(2) == Some(' ')) || - (trimmed.contains('[') && trimmed.contains("](")) - }) || - (text.matches('*').count() >= 2 && !text.contains("/*") && !text.contains("*/")) - } - - pub fn print(&self, text: &str) { - println!("{}", text); - } - - /// Smart print that automatically detects and renders markdown - pub fn print_smart(&self, text: &str) { - if self.has_markdown(text) { - self.print_markdown(text); - } else { - self.print(text); - } - } - - pub fn print_markdown(&self, markdown: &str) { - self.mad_skin.print_text(markdown); - } - - pub fn _print_status(&self, status: &str) { - println!("📊 {}", status); - } - - pub fn print_context(&self, used: u32, total: u32, percentage: f32) { - let total_dots = 10; - let filled_dots = ((percentage / 100.0) * total_dots as f32) as usize; - let empty_dots = total_dots.saturating_sub(filled_dots); - - let filled_str = "●".repeat(filled_dots); - let empty_str = "○".repeat(empty_dots); - - // Determine color based on percentage - let color = if percentage < 40.0 { - crossterm::style::Color::Green - } else if percentage < 60.0 { - crossterm::style::Color::Yellow - } else if percentage < 80.0 { - crossterm::style::Color::Rgb { r: 255, g: 165, b: 0 } // Orange - } else { - crossterm::style::Color::Red - }; - - // Print with colored progress bar - print!("Context: "); - print!("{}", SetForegroundColor(color)); - print!("{}{}", filled_str, empty_str); - print!("{}", ResetColor); - println!(" {:.0}% ({}/{} tokens)", percentage, used, total); - } - - pub fn print_context_thinning(&self, message: &str) { - // Animated highlight for context thinning - // Use bright cyan/green with a quick flash animation - - // Flash animation: print with bright background, then normal - let frames = vec![ - "\x1b[1;97;46m", // Frame 1: Bold white on cyan background - "\x1b[1;97;42m", // Frame 2: Bold white on green background - "\x1b[1;96;40m", // Frame 3: Bold cyan on black background - ]; - - println!(); - - // Quick flash animation - for frame in &frames { - print!("\r{} ✨ {} ✨\x1b[0m", frame, message); - let _ = io::stdout().flush(); - std::thread::sleep(std::time::Duration::from_millis(80)); - } - - // Final display with bright cyan and sparkle emojis - print!("\r\x1b[1;96m✨ {} ✨\x1b[0m", message); - println!(); - - // Add a subtle "success" indicator line - println!("\x1b[2;36m └─ Context optimized successfully\x1b[0m"); - println!(); - - let _ = io::stdout().flush(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_markdown_detection() { - let output = SimpleOutput::new(); - - // Should detect markdown - assert!(output.has_markdown("**bold text**")); - assert!(output.has_markdown("`code`")); - assert!(output.has_markdown("```\ncode block\n```")); - assert!(output.has_markdown("# Header")); - assert!(output.has_markdown("- list item")); - assert!(output.has_markdown("* list item")); - assert!(output.has_markdown("+ list item")); - assert!(output.has_markdown("1. numbered item")); - assert!(output.has_markdown("[link](url)")); - assert!(output.has_markdown("*italic* text")); - - // Should NOT detect markdown - assert!(!output.has_markdown("plain text")); - assert!(!output.has_markdown("file.txt")); - assert!(!output.has_markdown("/* comment */")); - assert!(!output.has_markdown("just one * asterisk")); - assert!(!output.has_markdown("📁 Workspace: /path/to/dir")); - assert!(!output.has_markdown("✅ Success message")); - } -} \ No newline at end of file