ralph-tui 2.7.0

Terminal UI for Ralph Orchestrator using ratatui
Documentation
//! # ralph-tui
//!
//! Terminal user interface for the Ralph Orchestrator framework.
//!
//! Built with `ratatui` and `crossterm`, this crate provides:
//! - Read-only observation dashboard for monitoring agent orchestration
//! - Real-time display of agent messages and state
//! - Keyboard navigation and search
//!
//! ## Data source modes
//!
//! The TUI operates in three modes:
//!
//! 1. **In-process** (default): Embedded in the orchestration loop. Receives
//!    events via an `EventBus` observer closure and streaming output via
//!    shared `Arc<Mutex<Vec<Line>>>` handles.
//!
//! 2. **RPC client**: Connects to a running `ralph-api` server over HTTP/WS
//!    and consumes the same RPC v1 stream the web dashboard uses.
//!    Start with [`Tui::connect`].
//!
//! 3. **Subprocess RPC**: Spawns `ralph run --rpc` as a subprocess and
//!    communicates via JSON lines over stdin/stdout. Start with [`Tui::spawn`].

mod app;
pub mod input;
pub mod rpc_bridge;
pub mod rpc_client;
pub mod rpc_source;
pub mod rpc_writer;
pub mod state;
pub mod state_mutations;
pub mod text_renderer;
pub mod widgets;

use anyhow::{Context, Result};
use ralph_proto::{Event, HatId};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tokio::process::{Child, ChildStdin, ChildStdout};
use tokio::sync::watch;
use tracing::info;

pub use app::{App, dispatch_action};
pub use rpc_client::RpcClient;
pub use rpc_source::run_rpc_event_reader;
pub use rpc_writer::RpcWriter;
pub use state::TuiState;
pub use text_renderer::text_to_lines;
pub use widgets::{footer, header};

/// Configuration for subprocess RPC mode.
struct SubprocessConfig {
    child: Child,
    stdin: ChildStdin,
    stdout: ChildStdout,
}

/// Main TUI handle that integrates with the event bus.
pub struct Tui {
    state: Arc<Mutex<TuiState>>,
    terminated_rx: Option<watch::Receiver<bool>>,
    /// Channel to signal main loop on Ctrl+C.
    /// In raw terminal mode, SIGINT is not generated by the OS, so TUI must
    /// detect Ctrl+C via crossterm events and signal the main loop directly.
    interrupt_tx: Option<watch::Sender<bool>>,
    /// When set, the TUI operates in RPC client mode (HTTP/WS).
    rpc_client: Option<RpcClient>,
    /// When set, the TUI operates in subprocess RPC mode.
    subprocess: Option<SubprocessConfig>,
}

impl Tui {
    /// Creates a new TUI instance with shared state (in-process mode).
    pub fn new() -> Self {
        Self {
            state: Arc::new(Mutex::new(TuiState::new())),
            terminated_rx: None,
            interrupt_tx: None,
            rpc_client: None,
            subprocess: None,
        }
    }

    /// Creates a TUI that connects to a running `ralph-api` server.
    ///
    /// The TUI will fetch initial state via HTTP and subscribe to the
    /// RPC v1 event stream over WebSocket. No in-process observer is needed.
    ///
    /// # Arguments
    /// * `base_url` — e.g. `"http://127.0.0.1:3000"`
    pub fn connect(base_url: &str) -> Result<Self> {
        let client = RpcClient::new(base_url)?;
        Ok(Self {
            state: Arc::new(Mutex::new(TuiState::new())),
            terminated_rx: None,
            interrupt_tx: None,
            rpc_client: Some(client),
            subprocess: None,
        })
    }

    /// Creates a TUI that spawns `ralph run --rpc` as a subprocess.
    ///
    /// The TUI reads JSON-RPC events from the subprocess stdout and sends
    /// commands to its stdin. This mode allows the TUI to run independently
    /// of the orchestration loop process.
    ///
    /// # Arguments
    /// * `args` - Arguments to pass to `ralph run --rpc` (e.g., `-p "prompt"`, `-c config.yml`)
    ///
    /// # Example
    /// ```no_run
    /// # use ralph_tui::Tui;
    /// # async fn example() -> anyhow::Result<()> {
    /// let tui = Tui::spawn(vec![
    ///     "-p".to_string(),
    ///     "Implement feature X".to_string(),
    ///     "-c".to_string(),
    ///     "ralph.yml".to_string(),
    /// ])?;
    /// tui.run().await?;
    /// # Ok(())
    /// # }
    /// ```
    pub fn spawn(args: Vec<String>) -> Result<Self> {
        use std::process::Stdio;
        use tokio::process::Command;

        // Build command: ralph run --rpc <args>
        // Redirect stderr to a log file to prevent child process tracing output
        // from corrupting the TUI display (ratatui runs in raw terminal mode).
        let stderr_stdio = match ralph_core::diagnostics::create_log_file(
            &std::env::current_dir().unwrap_or_default(),
        ) {
            Ok((file, path)) => {
                info!(log_file = %path.display(), "TUI subprocess stderr redirected to log file");
                Stdio::from(file)
            }
            Err(_) => Stdio::null(),
        };

        let mut cmd = Command::new("ralph");
        cmd.arg("run")
            .arg("--rpc")
            .args(&args)
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(stderr_stdio);

        let mut child = cmd.spawn().context("failed to spawn ralph subprocess")?;

        let stdin = child
            .stdin
            .take()
            .context("failed to capture subprocess stdin")?;
        let stdout = child
            .stdout
            .take()
            .context("failed to capture subprocess stdout")?;

        info!(args = ?args, "TUI spawned ralph subprocess in RPC mode");

        Ok(Self {
            state: Arc::new(Mutex::new(TuiState::new())),
            terminated_rx: None,
            interrupt_tx: None,
            rpc_client: None,
            subprocess: Some(SubprocessConfig {
                child,
                stdin,
                stdout,
            }),
        })
    }

    /// Sets the hat map for dynamic topic-to-hat resolution.
    ///
    /// This allows the TUI to display the correct hat for custom topics
    /// without hardcoding them in TuiState::update().
    #[must_use]
    pub fn with_hat_map(self, hat_map: HashMap<String, (HatId, String)>) -> Self {
        if let Ok(mut state) = self.state.lock() {
            *state = TuiState::with_hat_map(hat_map);
        }
        self
    }

    /// Sets the termination signal receiver for graceful shutdown.
    ///
    /// The TUI will exit when this receiver signals `true`.
    #[must_use]
    pub fn with_termination_signal(mut self, terminated_rx: watch::Receiver<bool>) -> Self {
        self.terminated_rx = Some(terminated_rx);
        self
    }

    /// Sets the interrupt channel for Ctrl+C signaling.
    ///
    /// In raw terminal mode, SIGINT is not generated by the OS when the user
    /// presses Ctrl+C. The TUI detects Ctrl+C via crossterm events and uses
    /// this channel to signal the main orchestration loop to terminate.
    #[must_use]
    pub fn with_interrupt_tx(mut self, interrupt_tx: watch::Sender<bool>) -> Self {
        self.interrupt_tx = Some(interrupt_tx);
        self
    }

    /// Sets the path to events.jsonl for direct guidance writes.
    #[must_use]
    pub fn with_events_path(self, path: std::path::PathBuf) -> Self {
        if let Ok(mut state) = self.state.lock() {
            state.events_path = Some(path);
        }
        self
    }

    /// Returns the shared state for external updates.
    pub fn state(&self) -> Arc<Mutex<TuiState>> {
        Arc::clone(&self.state)
    }

    /// Returns a handle to the guidance next-queue for draining in the loop runner.
    pub fn guidance_next_queue(&self) -> Arc<std::sync::Mutex<Vec<String>>> {
        let state = self.state.lock().unwrap();
        Arc::clone(&state.guidance_next_queue)
    }

    /// Returns an observer closure that updates TUI state from events.
    ///
    /// Only meaningful in in-process mode. In RPC mode, state is updated
    /// by the WebSocket bridge instead.
    pub fn observer(&self) -> impl Fn(&Event) + Send + 'static {
        let state = Arc::clone(&self.state);
        move |event: &Event| {
            if let Ok(mut s) = state.lock() {
                s.update(event);
            }
        }
    }

    /// Runs the TUI application loop.
    ///
    /// In **in-process mode**, requires `with_termination_signal()` to have
    /// been called first.
    ///
    /// In **RPC client mode** (HTTP/WS), the TUI manages its own lifecycle — the RPC
    /// bridge runs alongside the render loop and exits on Ctrl+C or `q`.
    ///
    /// In **subprocess RPC mode**, the TUI spawns an event reader to consume
    /// JSON events from the subprocess stdout and uses an RPC writer to send
    /// commands to the subprocess stdin.
    ///
    /// # Errors
    ///
    /// Returns an error if the terminal cannot be initialized or
    /// if the application loop encounters an unrecoverable error.
    pub async fn run(mut self) -> Result<()> {
        if let Some(subprocess) = self.subprocess.take() {
            // Subprocess RPC mode
            self.run_subprocess_mode(subprocess).await
        } else if let Some(client) = self.rpc_client.take() {
            // RPC client mode (HTTP/WS)
            self.run_rpc_client_mode(client).await
        } else {
            // In-process mode — require termination signal
            let terminated_rx = self
                .terminated_rx
                .expect("Termination signal not set - call with_termination_signal() first");
            let app = App::new(Arc::clone(&self.state), terminated_rx, self.interrupt_tx);
            app.run().await
        }
    }

    /// Runs the TUI in HTTP/WS RPC client mode.
    async fn run_rpc_client_mode(self, client: RpcClient) -> Result<()> {
        let (terminated_tx, terminated_rx) = watch::channel(false);

        // Spawn the RPC bridge as a background task
        let bridge_state = Arc::clone(&self.state);
        let cancel_rx = terminated_rx.clone();
        let bridge_handle = tokio::spawn(async move {
            if let Err(e) = rpc_bridge::run_rpc_bridge(client, bridge_state, cancel_rx).await {
                tracing::error!(error = %e, "RPC bridge exited with error");
            }
        });

        info!("TUI running in RPC client mode");

        // Run the TUI render/input loop
        let app = App::new(Arc::clone(&self.state), terminated_rx, self.interrupt_tx);
        let result = app.run().await;

        // Signal the bridge to stop and wait for it
        let _ = terminated_tx.send(true);
        let _ = bridge_handle.await;

        result
    }

    /// Runs the TUI in subprocess RPC mode.
    async fn run_subprocess_mode(self, subprocess: SubprocessConfig) -> Result<()> {
        let SubprocessConfig {
            mut child,
            stdin,
            stdout,
        } = subprocess;

        // Create termination signal
        let (terminated_tx, terminated_rx) = watch::channel(false);

        // Create RPC writer for sending commands
        let rpc_writer = RpcWriter::new(stdin);

        // Spawn the event reader as a background task
        let reader_state = Arc::clone(&self.state);
        let cancel_rx = terminated_rx.clone();
        let reader_handle = tokio::spawn(async move {
            rpc_source::run_rpc_event_reader(stdout, reader_state, cancel_rx).await;
        });

        info!("TUI running in subprocess RPC mode");

        // Run the TUI render/input loop with subprocess support
        let app = App::new_subprocess(Arc::clone(&self.state), terminated_rx, rpc_writer.clone());
        let result = app.run().await;

        // Signal cancellation
        let _ = terminated_tx.send(true);

        // Send abort to subprocess and close stdin
        let _ = rpc_writer.send_abort().await;
        let _ = rpc_writer.close().await;

        // Wait for reader to finish
        let _ = reader_handle.await;

        // Wait for subprocess to exit
        let _ = child.wait().await;

        result
    }
}

impl Default for Tui {
    fn default() -> Self {
        Self::new()
    }
}