mecha10-cli 0.6.3

Mecha10 CLI tool
Documentation
//! Interactive TUI event loop for dev mode

mod keys;

use super::health_tail::HealthTailer;
use super::lifecycle::cleanup;
use super::registry::NodeRegistry;
use crate::context::CliContext;
use crate::dev::node_selection::NodeToRun;
use crate::dev::termination::{classify_key, TerminationIntent};
use crate::types::ProjectConfig;
use crate::ui::LogBuffer;
use anyhow::Result;

/// Configuration for interactive mode
pub struct InteractiveModeConfig<'a> {
    pub project_config: &'a ProjectConfig,
    pub nodes_to_run: &'a [NodeToRun],
    pub project_name: &'a str,
}

/// Mutable state for interactive mode
pub struct InteractiveModeState {
    /// Node PID/failure bookkeeping, carried over from `DevSession` startup and updated by every
    /// `handle_mode_change` call. See `NodeRegistry`'s module docs for why this needs to persist
    /// beyond the single call that produced a given failure.
    pub node_registry: NodeRegistry,
    pub lifecycle: Option<crate::dev::lifecycle_adapter::CliLifecycleManager>,
    pub log_buffer: LogBuffer,
}

/// Run interactive mode with keyboard controls
pub async fn run_interactive_mode(
    ctx: &mut CliContext,
    config: InteractiveModeConfig<'_>,
    state: &mut InteractiveModeState,
) -> Result<()> {
    use crate::ui::DevModeTui;
    use crossterm::event::{self, Event};
    use crossterm::execute;
    use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
    use ratatui::{backend::CrosstermBackend, Terminal};
    use std::io::stdout;
    use std::sync::atomic::Ordering;
    use std::time::Duration;

    // Set up the single process-wide Ctrl+C handler (see `crate::dev::termination`).
    let running = crate::dev::termination::install_quit_signal()?;

    let redis_url = ctx.redis_url().to_string();

    // Setup terminal for TUI.
    // If enable_raw_mode fails (no TTY — SSH, CI, non-interactive shell) fall back to
    // a headless wait loop so spawned nodes keep running until Ctrl-C / SIGINT.
    if enable_raw_mode().is_err() {
        println!("\n(No interactive terminal — running in headless mode. Press Ctrl+C to stop.)\n");
        while running.load(Ordering::SeqCst) {
            tokio::time::sleep(Duration::from_millis(200)).await;
        }
        cleanup(ctx)?;
        return Ok(());
    }

    let mut stdout_handle = stdout();
    execute!(stdout_handle, EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(stdout_handle);
    let mut terminal = Terminal::new(backend)?;

    // Clear the terminal to ensure proper initial rendering
    terminal.clear()?;

    // Create dev mode TUI
    let current_mode = state
        .lifecycle
        .as_ref()
        .map(|l| l.current_mode().to_string())
        .unwrap_or_else(|| "startup".to_string());
    let running_nodes: Vec<String> = state.node_registry.running_node_names();
    let dashboard_url = config.project_config.environments.dashboard_url();
    let mut dev_tui = DevModeTui::new(state.log_buffer.clone(), current_mode, running_nodes, dashboard_url);

    // Relays node-runner's health-check/restart log lines (see `health_tail` module docs) into
    // `state.log_buffer` so a health transition shows up in the TUI's System Logs panel without
    // the user needing to go tail `logs/<node>.log` by hand. Throttled internally, so it's safe
    // to check unconditionally on every redraw tick below.
    let mut health_tailer = HealthTailer::default();

    // Main event loop with TUI
    let result = loop {
        // Check interrupt flag
        if !running.load(Ordering::SeqCst) {
            break Ok(());
        }

        health_tailer.poll(&state.node_registry.running_node_names(), &state.log_buffer);

        // Draw TUI
        if let Err(e) = terminal.draw(|f| dev_tui.draw(f)) {
            break Err(e.into());
        }

        // Poll for events
        if event::poll(Duration::from_millis(100))? {
            if let Event::Key(key_event) = event::read()? {
                // Termination-shaped keys (bare q/Esc/x -> StopCurrent, Ctrl+C/Ctrl+X ->
                // QuitAll) are classified uniformly via the shared `classify_key`, before any of
                // the dev-mode menu's own key handling below - see `crate::dev::termination`.
                if let Some(intent) = classify_key(&key_event) {
                    match intent {
                        TerminationIntent::QuitAll => {
                            running.store(false, Ordering::SeqCst);
                            break Ok(());
                        }
                        TerminationIntent::StopCurrent => {
                            match key_event.code {
                                crossterm::event::KeyCode::Char('x') => {
                                    // Same guard as before: stop-simulation is meaningless
                                    // outside simulation mode.
                                    if state.lifecycle.as_ref().map(|l| l.current_mode().to_lowercase())
                                        == Some("simulation".to_string())
                                    {
                                        keys::handle_stop_simulation_key(
                                            ctx,
                                            &config,
                                            state,
                                            &mut terminal,
                                            &mut dev_tui,
                                        )
                                        .await?;
                                    }
                                }
                                _ => {
                                    // Bare 'q'/Esc: this is the top-level dev-mode menu, so
                                    // there's no parent context to return to - "stop current"
                                    // here means quit `mecha10 dev` entirely, matching the
                                    // pre-existing bare 'q' behavior.
                                    running.store(false, Ordering::SeqCst);
                                    break Ok(());
                                }
                            }
                        }
                    }
                    continue;
                }

                // Handle key and get the character
                if let Some(c) = dev_tui.handle_key(key_event) {
                    match c {
                        's' => {
                            keys::handle_simulation_key(
                                ctx,
                                &config,
                                state,
                                &mut terminal,
                                &mut dev_tui,
                                &redis_url,
                                &running,
                            )
                            .await?;
                        }
                        't' => {
                            keys::handle_teleop_key(
                                ctx,
                                &config,
                                state,
                                &mut terminal,
                                &mut dev_tui,
                                &redis_url,
                                &running,
                            )
                            .await?;
                        }
                        _ => {}
                    }
                }
            }
        }
    };

    // Cleanup terminal
    disable_raw_mode()?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
    terminal.show_cursor()?;

    // Cleanup processes
    cleanup(ctx)?;

    result
}

// `is_dev_quit_key`'s unit tests moved to `crate::dev::termination` alongside `classify_key`,
// which now supplies this loop's quit/stop classification directly - see that module's tests
// (`plain_x_without_control_modifier_is_stop_current` is the direct regression test for the
// reported bug: bare 'x' now classifies as `StopCurrent`, not the old "ignored outside
// simulation mode, meant nothing inside teleop" behavior).