Skip to main content

ralph_tui/
lib.rs

1//! # ralph-tui
2//!
3//! Terminal user interface for the Ralph Orchestrator framework.
4//!
5//! Built with `ratatui` and `crossterm`, this crate provides:
6//! - Read-only observation dashboard for monitoring agent orchestration
7//! - Real-time display of agent messages and state
8//! - Keyboard navigation and search
9
10mod app;
11pub mod input;
12pub mod state;
13pub mod widgets;
14
15use anyhow::Result;
16use app::App;
17use ralph_proto::{Event, HatId};
18use std::collections::HashMap;
19use std::sync::{Arc, Mutex};
20use tokio::sync::watch;
21
22pub use app::dispatch_action;
23pub use state::TuiState;
24pub use widgets::{footer, header};
25
26/// Main TUI handle that integrates with the event bus.
27pub struct Tui {
28    state: Arc<Mutex<TuiState>>,
29    terminated_rx: Option<watch::Receiver<bool>>,
30    /// Channel to signal main loop on Ctrl+C.
31    /// In raw terminal mode, SIGINT is not generated by the OS, so TUI must
32    /// detect Ctrl+C via crossterm events and signal the main loop directly.
33    interrupt_tx: Option<watch::Sender<bool>>,
34}
35
36impl Tui {
37    /// Creates a new TUI instance with shared state.
38    pub fn new() -> Self {
39        Self {
40            state: Arc::new(Mutex::new(TuiState::new())),
41            terminated_rx: None,
42            interrupt_tx: None,
43        }
44    }
45
46    /// Sets the hat map for dynamic topic-to-hat resolution.
47    ///
48    /// This allows the TUI to display the correct hat for custom topics
49    /// without hardcoding them in TuiState::update().
50    #[must_use]
51    pub fn with_hat_map(self, hat_map: HashMap<String, (HatId, String)>) -> Self {
52        if let Ok(mut state) = self.state.lock() {
53            *state = TuiState::with_hat_map(hat_map);
54        }
55        self
56    }
57
58    /// Sets the termination signal receiver for graceful shutdown.
59    ///
60    /// The TUI will exit when this receiver signals `true`.
61    #[must_use]
62    pub fn with_termination_signal(mut self, terminated_rx: watch::Receiver<bool>) -> Self {
63        self.terminated_rx = Some(terminated_rx);
64        self
65    }
66
67    /// Sets the interrupt channel for Ctrl+C signaling.
68    ///
69    /// In raw terminal mode, SIGINT is not generated by the OS when the user
70    /// presses Ctrl+C. The TUI detects Ctrl+C via crossterm events and uses
71    /// this channel to signal the main orchestration loop to terminate.
72    #[must_use]
73    pub fn with_interrupt_tx(mut self, interrupt_tx: watch::Sender<bool>) -> Self {
74        self.interrupt_tx = Some(interrupt_tx);
75        self
76    }
77
78    /// Returns the shared state for external updates.
79    pub fn state(&self) -> Arc<Mutex<TuiState>> {
80        Arc::clone(&self.state)
81    }
82
83    /// Returns an observer closure that updates TUI state from events.
84    pub fn observer(&self) -> impl Fn(&Event) + Send + 'static {
85        let state = Arc::clone(&self.state);
86        move |event: &Event| {
87            if let Ok(mut s) = state.lock() {
88                s.update(event);
89            }
90        }
91    }
92
93    /// Runs the TUI application loop.
94    ///
95    /// # Panics
96    ///
97    /// Panics if `with_termination_signal()` was not called before running.
98    ///
99    /// # Errors
100    ///
101    /// Returns an error if the terminal cannot be initialized or
102    /// if the application loop encounters an unrecoverable error.
103    pub async fn run(self) -> Result<()> {
104        let terminated_rx = self
105            .terminated_rx
106            .expect("Termination signal not set - call with_termination_signal() first");
107        let app = App::new(Arc::clone(&self.state), terminated_rx, self.interrupt_tx);
108        app.run().await
109    }
110}
111
112impl Default for Tui {
113    fn default() -> Self {
114        Self::new()
115    }
116}