o7 0.1.0

O7 workflow DSL runner
Documentation
//! TUI module — interactive terminal UI for the O7 workflow runner.
//!
//! Provides a ratatui-based TUI with:
//! - Step list with status icons and spinner animation
//! - Header with workflow name, status badge, and elapsed time
//! - Command palette (Ctrl+P)
//! - Detail panel for step inspection (v)
//! - Keyboard navigation (j/k, Up/Down, q to quit)

pub mod app;
pub mod command_palette;
pub mod event;
pub mod qa_protocol;
pub mod qa_ui;
pub mod qa_watcher;
pub mod ui;

use std::io::{self, stdout};
use std::pin::Pin;
use std::sync::Arc;

use crossterm::execute;
use crossterm::terminal::{
    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use tokio::sync::mpsc;

use crate::engine::engine::ExecutionEngine;
use crate::engine::types::{ExecutionEvent, HarnessDispatchFn, OnEventCallback, OnSaveCallback};
use crate::parser::ast::WorkflowDecl;

use self::app::{App, AppMessage};

/// Run the interactive TUI for a workflow.
///
/// Sets up the terminal, creates the engine with an event channel, spawns
/// engine execution in a background tokio task, runs the event loop, and
/// restores the terminal on exit (even on panic).
pub async fn run_tui(
    workflows: Vec<WorkflowDecl>,
    project_root: &str,
    workflow_name: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    // Set up terminal.
    enable_raw_mode()?;
    let mut stdout = stdout();
    execute!(stdout, EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    // Ensure terminal is restored on panic.
    let panic_hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        let _ = disable_raw_mode();
        let _ = execute!(io::stdout(), LeaveAlternateScreen);
        panic_hook(info);
    }));

    let project_root_owned = project_root.to_string();

    // Create the event channel for engine -> TUI communication.
    let (tx, rx) = mpsc::unbounded_channel::<AppMessage>();

    // Shared run_id holder so the QA watcher and submit callback can
    // find the run directory once the engine produces its first event.
    let run_id_holder: Arc<std::sync::RwLock<Option<String>>> =
        Arc::new(std::sync::RwLock::new(None));
    let run_id_for_event = run_id_holder.clone();
    let run_id_for_submit = run_id_holder.clone();

    // Create the engine with event and save callbacks.
    let event_tx = tx.clone();
    let on_event: OnEventCallback = Box::new(move |event: &ExecutionEvent| {
        // Capture run_id from first event.
        if let Ok(mut guard) = run_id_for_event.write()
            && guard.is_none()
        {
            *guard = Some(extract_run_id(event));
        }
        let _ = event_tx.send(AppMessage::EngineEvent(event.clone()));
    });

    let on_save: OnSaveCallback = Box::new({
        let project_root = project_root_owned.clone();
        move |state| {
            let persisted = crate::state::adapter::to_persisted_state(state);
            if let Err(e) = crate::state::persistence::save_state(&persisted, &project_root) {
                eprintln!("[o7] Warning: failed to save state: {}", e);
            }
        }
    });

    let dispatch = make_tui_dispatch_fn(project_root_owned.clone(), run_id_holder.clone())
        .map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;

    let mut engine = ExecutionEngine::new(workflows.clone(), dispatch, Some(on_event), Some(on_save));
    engine.suspend_on_pause = true;

    // Share the pause flag and notify between the TUI and engine.
    let pause_flag = engine.pause_requested.clone();
    let pause_notify = engine.pause_notify.clone();

    // Create app state with the shared pause flag and notify.
    let mut app = App::new(&workflows, workflow_name, pause_flag.clone(), pause_notify);

    // Spawn engine execution in a background thread with its own tokio runtime,
    // because ExecutionEngine's futures are not Send.
    let wf_name = workflow_name.to_string();
    let engine_tx = tx.clone();
    std::thread::spawn(move || {
        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .expect("Failed to create engine runtime");
        rt.block_on(async move {
            let (_run_id, _events) = engine.start_run(&wf_name).await;
            let _ = engine_tx.send(AppMessage::EngineFinished);
        });
    });

    // QA watcher: starts as None, lazily created by the event loop
    // once the run_id is known.
    let qa_watcher = None::<self::qa_watcher::QAWatcher>;

    // QA submit callback: writes answer files atomically.
    let qa_project_root = project_root_owned.clone();
    let on_qa_submit: event::QASubmitCallback = Box::new(move |seq, answers_file| {
        if let Ok(guard) = run_id_for_submit.read()
            && let Some(ref run_id) = *guard
        {
            let qa_dir = format!("{}/.7/runs/{}/qa", qa_project_root, run_id);
            if let Err(e) = std::fs::create_dir_all(&qa_dir) {
                eprintln!("[o7] Warning: failed to create qa dir: {}", e);
                return;
            }
            let filename = format!(
                "q-answers-{}.json",
                qa_protocol::format_seq(seq)
            );
            let filepath = format!("{}/{}", qa_dir, filename);
            let tmp_filepath = format!("{}.tmp", filepath);
            match serde_json::to_string_pretty(answers_file) {
                Ok(json) => {
                    if let Err(e) = std::fs::write(&tmp_filepath, &json) {
                        eprintln!("[o7] Warning: failed to write answer file: {}", e);
                        return;
                    }
                    if let Err(e) = std::fs::rename(&tmp_filepath, &filepath) {
                        eprintln!("[o7] Warning: failed to rename answer file: {}", e);
                    }
                }
                Err(e) => {
                    eprintln!("[o7] Warning: failed to serialize answers: {}", e);
                }
            }
        }
    });

    // Run the event loop (blocks until quit).
    let result = event::run_event_loop(
        &mut terminal,
        &mut app,
        rx,
        qa_watcher,
        Some(on_qa_submit),
        run_id_holder,
        project_root_owned.clone(),
    )
    .await;

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

    result.map_err(|e| e.into())
}

/// Extract the run_id from any execution event.
fn extract_run_id(event: &ExecutionEvent) -> String {
    match event {
        ExecutionEvent::StepStarted { runId, .. }
        | ExecutionEvent::StepCompleted { runId, .. }
        | ExecutionEvent::StepFailed { runId, .. }
        | ExecutionEvent::BranchStarted { runId, .. }
        | ExecutionEvent::BranchCompleted { runId, .. }
        | ExecutionEvent::BranchFailed { runId, .. }
        | ExecutionEvent::JoinStarted { runId, .. }
        | ExecutionEvent::RunCompleted { runId }
        | ExecutionEvent::RunFailed { runId, .. }
        | ExecutionEvent::RunPaused { runId, .. }
        | ExecutionEvent::CheckEvaluated { runId, .. }
        | ExecutionEvent::MatchEvaluated { runId, .. }
        | ExecutionEvent::SafeBoundary { runId, .. } => runId.clone(),
    }
}

/// Create a HarnessDispatchFn for the TUI (same logic as the CLI).
/// Loads the harness config once and reuses it for every dispatch call.
fn make_tui_dispatch_fn(
    project_root: String,
    run_id_holder: Arc<std::sync::RwLock<Option<String>>>,
) -> Result<HarnessDispatchFn, String> {
    let config = match crate::harness::config::load_harness_config(&project_root) {
        Ok(c) => c,
        Err(_) => crate::harness::types::HarnessConfig { harness: std::collections::HashMap::new() },
    };
    let config = Arc::new(config);
    Ok(Arc::new(move |exec_block| {
        let root = project_root.clone();
        let block = exec_block.clone();
        let config = config.clone();
        let run_id = run_id_holder.clone();
        Box::pin(async move {
            let ctx = crate::harness::RunContext {
                run_id,
                project_root: root.clone(),
            };
            crate::harness::dispatch_exec(&config, &root, &block, Some(&ctx)).await
        })
            as Pin<
                Box<
                    dyn std::future::Future<
                            Output = Result<crate::harness::types::ExecResult, String>,
                        > + Send,
                >,
            >
    }))
}