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}