mecha10-cli 0.1.47

Mecha10 CLI tool
Documentation
//! Advanced TUI for dev mode with logs sidebar using ratatui
//!
//! Provides a split-pane terminal interface:
//! - Left: Main content (teleop controls, status, etc.)
//! - Right: Logs sidebar (scrollable, real-time)
//!
//! This is an example implementation showing how to use ratatui
//! for more sophisticated terminal UIs.

use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
    layout::{Constraint, Direction, Layout},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, List, ListItem, Paragraph},
    Frame,
};
use std::sync::{Arc, Mutex};

/// Shared log buffer for collecting logs from nodes
#[derive(Clone)]
pub struct LogBuffer {
    logs: Arc<Mutex<Vec<String>>>,
    max_size: usize,
}

impl LogBuffer {
    /// Create a new log buffer
    pub fn new(max_size: usize) -> Self {
        Self {
            logs: Arc::new(Mutex::new(Vec::new())),
            max_size,
        }
    }

    /// Add a log line
    pub fn push(&self, log: String) {
        let mut logs = self.logs.lock().unwrap();
        logs.push(log);

        // Keep only last N logs
        if logs.len() > self.max_size {
            let excess = logs.len() - self.max_size;
            logs.drain(0..excess);
        }
    }

    /// Get all current logs
    pub fn get_logs(&self) -> Vec<String> {
        self.logs.lock().unwrap().clone()
    }

    /// Clear all logs
    #[allow(dead_code)]
    pub fn clear(&self) {
        self.logs.lock().unwrap().clear();
    }
}

/// TUI for main dev mode interface
pub struct DevModeTui {
    log_buffer: LogBuffer,
    scroll_offset: usize,
    current_mode: String,
    running_nodes: Vec<String>,
    key_pressed: Option<char>,
    dashboard_url: String,
}

impl DevModeTui {
    /// Create a new dev mode TUI
    ///
    /// # Arguments
    ///
    /// * `log_buffer` - Shared log buffer for collecting logs
    /// * `current_mode` - Current lifecycle mode name
    /// * `running_nodes` - List of currently running node names
    /// * `dashboard_url` - Dashboard URL to display in footer (derived from mecha10.json)
    pub fn new(log_buffer: LogBuffer, current_mode: String, running_nodes: Vec<String>, dashboard_url: String) -> Self {
        Self {
            log_buffer,
            scroll_offset: 0,
            current_mode,
            running_nodes,
            key_pressed: None,
            dashboard_url,
        }
    }

    /// Update mode and running nodes
    pub fn update_status(&mut self, mode: String, running_nodes: Vec<String>) {
        self.current_mode = mode;
        self.running_nodes = running_nodes;
    }

    /// Draw the TUI (single frame)
    pub fn draw(&mut self, f: &mut Frame) {
        // Split terminal vertically: content area + footer
        let vertical_chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Min(10),   // Main content area
                Constraint::Length(3), // Footer
            ])
            .split(f.area());

        // Split main content horizontally: instructions + logs sidebar
        let horizontal_chunks = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([
                Constraint::Percentage(60), // Main instructions
                Constraint::Percentage(40), // Logs sidebar
            ])
            .split(vertical_chunks[0]);

        // Draw panels
        self.draw_instructions(f, horizontal_chunks[0]);
        self.draw_logs_sidebar(f, horizontal_chunks[1]);
        self.draw_footer(f, vertical_chunks[1]);
    }

    /// Draw main instructions panel
    fn draw_instructions(&self, f: &mut Frame, area: ratatui::layout::Rect) {
        let mut text = vec![
            Line::from(vec![Span::styled(
                "🚀 MECHA10 DEVELOPMENT MODE",
                Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
            )]),
            Line::from(""),
            Line::from(vec![
                Span::styled("Mode: ", Style::default().add_modifier(Modifier::BOLD)),
                Span::styled(
                    &self.current_mode,
                    Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
                ),
            ]),
            Line::from(""),
        ];

        // Show running nodes
        if !self.running_nodes.is_empty() {
            text.push(Line::from(vec![Span::styled(
                "Running Nodes:",
                Style::default().add_modifier(Modifier::BOLD),
            )]));
            for node in &self.running_nodes {
                text.push(Line::from(vec![
                    Span::raw(""),
                    Span::styled(node, Style::default().fg(Color::Green)),
                ]));
            }
            text.push(Line::from(""));
        }

        text.extend(vec![
            Line::from("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"),
            Line::from(""),
            Line::from(vec![Span::styled(
                "Commands:",
                Style::default().add_modifier(Modifier::BOLD),
            )]),
        ]);

        // Show mode-switching commands based on current mode
        let current_mode_lower = self.current_mode.to_lowercase();
        if current_mode_lower == "simulation" {
            // In simulation mode: only show stop option
            text.push(Line::from(vec![Span::styled(
                "  x - Stop simulation mode",
                Style::default().fg(Color::Yellow),
            )]));
        } else if current_mode_lower == "teleop" {
            // In teleop mode: show switch to simulation
            text.push(Line::from("  s - Switch to simulation mode"));
        } else {
            // In other modes (e.g., dev): show both options
            text.push(Line::from("  s - Switch to simulation mode"));
            text.push(Line::from("  t - Switch to teleop mode"));
        }

        text.extend(vec![
            Line::from(""),
            Line::from("  j/k - Scroll logs (or PgUp/PgDn)"),
            Line::from(""),
            Line::from(vec![Span::styled(
                "  Ctrl+C/Ctrl+X - Exit",
                Style::default().fg(Color::Red),
            )]),
        ]);

        // Show last key pressed for debugging
        if let Some(key) = self.key_pressed {
            text.push(Line::from(""));
            text.push(Line::from(vec![
                Span::raw("Last key: "),
                Span::styled(key.to_string(), Style::default().fg(Color::Yellow)),
            ]));
        }

        let paragraph = Paragraph::new(text).block(
            Block::default()
                .borders(Borders::ALL)
                .title(" Dev Mode ")
                .border_style(Style::default().fg(Color::Cyan)),
        );

        f.render_widget(paragraph, area);
    }

    /// Draw logs sidebar
    fn draw_logs_sidebar(&self, f: &mut Frame, area: ratatui::layout::Rect) {
        let logs = self.log_buffer.get_logs();

        // Convert logs to list items with color coding and icons
        let items: Vec<ListItem> = logs
            .iter()
            .skip(self.scroll_offset)
            .map(|log| {
                let (style, icon) = if log.contains("ERROR") {
                    (Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), "")
                } else if log.contains("WARN") {
                    (Style::default().fg(Color::Yellow), "⚠️ ")
                } else if log.contains("MODE:") {
                    (Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), "")
                } else if log.contains("STARTED") || log.contains("") {
                    (Style::default().fg(Color::Green), "")
                } else if log.contains("STOPPED") || log.contains("🛑") {
                    (Style::default().fg(Color::Red), "🛑")
                } else if log.contains("INFO") {
                    (Style::default().fg(Color::Green), "ℹ️ ")
                } else if log.contains("DEBUG") {
                    (Style::default().fg(Color::Blue), "🔍")
                } else {
                    (Style::default().fg(Color::White), "  ")
                };

                // Don't add icon prefix if log already starts with an emoji icon
                let log_trimmed = log.trim_start();
                let already_has_icon = log_trimmed.starts_with("")
                    || log_trimmed.starts_with("")
                    || log_trimmed.starts_with("⚠️")
                    || log_trimmed.starts_with("🛑")
                    || log_trimmed.starts_with("▶️")
                    || log_trimmed.starts_with("🔄");
                let formatted_log = if already_has_icon {
                    format!("  {}", log)
                } else {
                    format!("{} {}", icon, log)
                };
                ListItem::new(formatted_log).style(style)
            })
            .collect();

        let title = if logs.is_empty() {
            " System Logs (waiting...) ".to_string()
        } else {
            format!(" System Logs ({}) ", logs.len())
        };

        let list = List::new(items).block(
            Block::default()
                .borders(Borders::ALL)
                .title(title)
                .border_style(Style::default().fg(Color::Yellow)),
        );

        f.render_widget(list, area);
    }

    /// Draw footer with links
    fn draw_footer(&self, f: &mut Frame, area: ratatui::layout::Rect) {
        let footer_text = vec![Line::from(vec![
            Span::raw("  "),
            Span::styled(
                "Dashboard:",
                Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
            ),
            Span::raw(format!(" {}  ", self.dashboard_url)),
            Span::styled("Docs:", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
            Span::raw(" https://mecha10.dev  "),
            Span::styled(
                "Ctrl+C/X:",
                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
            ),
            Span::raw(" Exit"),
        ])];

        let footer = Paragraph::new(footer_text).block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(Color::DarkGray)),
        );

        f.render_widget(footer, area);
    }

    /// Handle keyboard input - returns the key pressed
    pub fn handle_key(&mut self, key: KeyEvent) -> Option<char> {
        match key.code {
            // Scroll logs (PgUp/PgDn or Ctrl+U/D for laptops) - check these first before catch-all Char
            KeyCode::PageUp | KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.scroll_offset = self.scroll_offset.saturating_sub(10);
                None
            }
            KeyCode::PageDown | KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                let logs = self.log_buffer.get_logs();
                self.scroll_offset = (self.scroll_offset + 10).min(logs.len().saturating_sub(1));
                None
            }
            // Single-line scroll with j/k (vim-style)
            KeyCode::Char('k') if key.modifiers.is_empty() => {
                self.scroll_offset = self.scroll_offset.saturating_sub(1);
                None
            }
            KeyCode::Char('j') if key.modifiers.is_empty() => {
                let logs = self.log_buffer.get_logs();
                self.scroll_offset = (self.scroll_offset + 1).min(logs.len().saturating_sub(1));
                None
            }
            // Catch-all for regular characters (mode switching: s, t, etc.)
            KeyCode::Char(c) => {
                self.key_pressed = Some(c);
                Some(c)
            }
            _ => None,
        }
    }
}

/// Example: Subscribe to Redis logs and populate buffer
///
/// This would run in a background task to collect logs from Redis pub/sub.
/// Note: This is example code showing the pattern - actual implementation
/// would depend on your logging setup.
///
/// Example usage:
/// ```rust,ignore
/// let log_buffer = LogBuffer::new(1000);
/// tokio::spawn({
///     let buffer = log_buffer.clone();
///     async move {
///         subscribe_to_logs(buffer, redis_url).await
///     }
/// });
/// ```
#[allow(dead_code)]
pub async fn subscribe_to_logs(_log_buffer: LogBuffer, _redis_url: String) -> Result<()> {
    // Example implementation - commented out as it's reference code
    // In a real implementation, you would:
    // 1. Create a Redis pubsub connection
    // 2. Subscribe to log topics (e.g., "/logs/*")
    // 3. Stream messages into log_buffer
    //
    // Example:
    // let client = redis::Client::open(redis_url)?;
    // let conn = client.get_connection()?;
    // let mut pubsub = conn.as_pubsub();
    // pubsub.psubscribe("/logs/*")?;
    //
    // loop {
    //     let msg = pubsub.get_message()?;
    //     if let Ok(log_line) = msg.get_payload::<String>() {
    //         log_buffer.push(log_line);
    //     }
    // }

    Ok(())
}