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//!
10//! ## Data source modes
11//!
12//! The TUI operates in three modes:
13//!
14//! 1. **In-process** (default): Embedded in the orchestration loop. Receives
15//! events via an `EventBus` observer closure and streaming output via
16//! shared `Arc<Mutex<Vec<Line>>>` handles.
17//!
18//! 2. **RPC client**: Connects to a running `ralph-api` server over HTTP/WS
19//! and consumes the same RPC v1 stream the web dashboard uses.
20//! Start with [`Tui::connect`].
21//!
22//! 3. **Subprocess RPC**: Spawns `ralph run --rpc` as a subprocess and
23//! communicates via JSON lines over stdin/stdout. Start with [`Tui::spawn`].
24
25mod app;
26pub mod input;
27pub mod rpc_bridge;
28pub mod rpc_client;
29pub mod rpc_source;
30pub mod rpc_writer;
31pub mod state;
32pub mod state_mutations;
33pub mod text_renderer;
34pub mod update_check;
35pub mod widgets;
36
37use anyhow::{Context, Result};
38use ralph_proto::{Event, HatId};
39use std::collections::HashMap;
40use std::sync::{Arc, Mutex};
41use tokio::process::{Child, ChildStdin, ChildStdout};
42use tokio::sync::watch;
43use tracing::info;
44
45pub use app::{App, dispatch_action};
46pub use rpc_client::RpcClient;
47pub use rpc_source::run_rpc_event_reader;
48pub use rpc_writer::RpcWriter;
49pub use state::TuiState;
50pub use text_renderer::text_to_lines;
51pub use widgets::{footer, header};
52
53/// Configuration for subprocess RPC mode.
54struct SubprocessConfig {
55 child: Child,
56 stdin: ChildStdin,
57 stdout: ChildStdout,
58}
59
60fn detect_current_branch() -> Option<String> {
61 let cwd = std::env::current_dir().ok()?;
62 ralph_core::get_current_branch(cwd).ok()
63}
64
65fn initial_state() -> Arc<Mutex<TuiState>> {
66 let mut state = TuiState::new();
67 state.set_current_branch(detect_current_branch());
68 Arc::new(Mutex::new(state))
69}
70
71/// Main TUI handle that integrates with the event bus.
72pub struct Tui {
73 state: Arc<Mutex<TuiState>>,
74 terminated_rx: Option<watch::Receiver<bool>>,
75 /// Channel to signal main loop on Ctrl+C.
76 /// In raw terminal mode, SIGINT is not generated by the OS, so TUI must
77 /// detect Ctrl+C via crossterm events and signal the main loop directly.
78 interrupt_tx: Option<watch::Sender<bool>>,
79 /// When set, the TUI operates in RPC client mode (HTTP/WS).
80 rpc_client: Option<RpcClient>,
81 /// When set, the TUI operates in subprocess RPC mode.
82 subprocess: Option<SubprocessConfig>,
83}
84
85impl Tui {
86 /// Creates a new TUI instance with shared state (in-process mode).
87 pub fn new() -> Self {
88 Self {
89 state: initial_state(),
90 terminated_rx: None,
91 interrupt_tx: None,
92 rpc_client: None,
93 subprocess: None,
94 }
95 }
96
97 /// Creates a TUI that connects to a running `ralph-api` server.
98 ///
99 /// The TUI will fetch initial state via HTTP and subscribe to the
100 /// RPC v1 event stream over WebSocket. No in-process observer is needed.
101 ///
102 /// # Arguments
103 /// * `base_url` — e.g. `"http://127.0.0.1:3000"`
104 pub fn connect(base_url: &str) -> Result<Self> {
105 let client = RpcClient::new(base_url)?;
106 Ok(Self {
107 state: initial_state(),
108 terminated_rx: None,
109 interrupt_tx: None,
110 rpc_client: Some(client),
111 subprocess: None,
112 })
113 }
114
115 /// Creates a TUI that spawns `ralph run --rpc` as a subprocess.
116 ///
117 /// The TUI reads JSON-RPC events from the subprocess stdout and sends
118 /// commands to its stdin. This mode allows the TUI to run independently
119 /// of the orchestration loop process.
120 ///
121 /// # Arguments
122 /// * `args` - Arguments to pass to `ralph run --rpc` (e.g., `-p "prompt"`, `-c config.yml`)
123 ///
124 /// # Example
125 /// ```no_run
126 /// # use ralph_tui::Tui;
127 /// # async fn example() -> anyhow::Result<()> {
128 /// let tui = Tui::spawn(vec![
129 /// "-p".to_string(),
130 /// "Implement feature X".to_string(),
131 /// "-c".to_string(),
132 /// "ralph.yml".to_string(),
133 /// ])?;
134 /// tui.run().await?;
135 /// # Ok(())
136 /// # }
137 /// ```
138 pub fn spawn(args: Vec<String>) -> Result<Self> {
139 use std::process::Stdio;
140 use tokio::process::Command;
141
142 // Build command: ralph run --rpc <args>
143 // Redirect stderr to a log file to prevent child process tracing output
144 // from corrupting the TUI display (ratatui runs in raw terminal mode).
145 let stderr_stdio = match ralph_core::diagnostics::create_log_file(
146 &std::env::current_dir().unwrap_or_default(),
147 ) {
148 Ok((file, path)) => {
149 info!(log_file = %path.display(), "TUI subprocess stderr redirected to log file");
150 Stdio::from(file)
151 }
152 Err(_) => Stdio::null(),
153 };
154
155 let mut cmd = Command::new("ralph");
156 cmd.arg("run")
157 .arg("--rpc")
158 .args(&args)
159 .stdin(Stdio::piped())
160 .stdout(Stdio::piped())
161 .stderr(stderr_stdio);
162
163 let mut child = cmd.spawn().context("failed to spawn ralph subprocess")?;
164
165 let stdin = child
166 .stdin
167 .take()
168 .context("failed to capture subprocess stdin")?;
169 let stdout = child
170 .stdout
171 .take()
172 .context("failed to capture subprocess stdout")?;
173
174 info!(args = ?args, "TUI spawned ralph subprocess in RPC mode");
175
176 Ok(Self {
177 state: initial_state(),
178 terminated_rx: None,
179 interrupt_tx: None,
180 rpc_client: None,
181 subprocess: Some(SubprocessConfig {
182 child,
183 stdin,
184 stdout,
185 }),
186 })
187 }
188
189 /// Sets the hat map for dynamic topic-to-hat resolution.
190 ///
191 /// This allows the TUI to display the correct hat for custom topics
192 /// without hardcoding them in TuiState::update().
193 #[must_use]
194 pub fn with_hat_map(self, hat_map: HashMap<String, (HatId, String)>) -> Self {
195 if let Ok(mut state) = self.state.lock() {
196 state.set_hat_map(hat_map);
197 }
198 self
199 }
200
201 /// Sets the termination signal receiver for graceful shutdown.
202 ///
203 /// The TUI will exit when this receiver signals `true`.
204 #[must_use]
205 pub fn with_termination_signal(mut self, terminated_rx: watch::Receiver<bool>) -> Self {
206 self.terminated_rx = Some(terminated_rx);
207 self
208 }
209
210 /// Sets the interrupt channel for Ctrl+C signaling.
211 ///
212 /// In raw terminal mode, SIGINT is not generated by the OS when the user
213 /// presses Ctrl+C. The TUI detects Ctrl+C via crossterm events and uses
214 /// this channel to signal the main orchestration loop to terminate.
215 #[must_use]
216 pub fn with_interrupt_tx(mut self, interrupt_tx: watch::Sender<bool>) -> Self {
217 self.interrupt_tx = Some(interrupt_tx);
218 self
219 }
220
221 /// Sets the path to events.jsonl for direct guidance writes.
222 #[must_use]
223 pub fn with_events_path(self, path: std::path::PathBuf) -> Self {
224 if let Ok(mut state) = self.state.lock() {
225 state.events_path = Some(path);
226 }
227 self
228 }
229
230 /// Sets the path to the urgent-steer marker file for immediate `!` gating.
231 #[must_use]
232 pub fn with_urgent_steer_path(self, path: std::path::PathBuf) -> Self {
233 if let Ok(mut state) = self.state.lock() {
234 state.urgent_steer_path = Some(path);
235 }
236 self
237 }
238
239 /// Returns the shared state for external updates.
240 pub fn state(&self) -> Arc<Mutex<TuiState>> {
241 Arc::clone(&self.state)
242 }
243
244 /// Returns a handle to the guidance next-queue for draining in the loop runner.
245 pub fn guidance_next_queue(&self) -> Arc<std::sync::Mutex<Vec<String>>> {
246 let state = self.state.lock().unwrap();
247 Arc::clone(&state.guidance_next_queue)
248 }
249
250 /// Returns an observer closure that updates TUI state from events.
251 ///
252 /// Only meaningful in in-process mode. In RPC mode, state is updated
253 /// by the WebSocket bridge instead.
254 pub fn observer(&self) -> impl Fn(&Event) + Send + 'static {
255 let state = Arc::clone(&self.state);
256 move |event: &Event| {
257 if let Ok(mut s) = state.lock() {
258 s.update(event);
259 }
260 }
261 }
262
263 /// Runs the TUI application loop.
264 ///
265 /// In **in-process mode**, requires `with_termination_signal()` to have
266 /// been called first.
267 ///
268 /// In **RPC client mode** (HTTP/WS), the TUI manages its own lifecycle — the RPC
269 /// bridge runs alongside the render loop and exits on Ctrl+C or `q`.
270 ///
271 /// In **subprocess RPC mode**, the TUI spawns an event reader to consume
272 /// JSON events from the subprocess stdout and uses an RPC writer to send
273 /// commands to the subprocess stdin.
274 ///
275 /// # Errors
276 ///
277 /// Returns an error if the terminal cannot be initialized or
278 /// if the application loop encounters an unrecoverable error.
279 pub async fn run(mut self) -> Result<()> {
280 if let Some(subprocess) = self.subprocess.take() {
281 // Subprocess RPC mode
282 self.run_subprocess_mode(subprocess).await
283 } else if let Some(client) = self.rpc_client.take() {
284 // RPC client mode (HTTP/WS)
285 self.run_rpc_client_mode(client).await
286 } else {
287 // In-process mode — require termination signal
288 let terminated_rx = self
289 .terminated_rx
290 .expect("Termination signal not set - call with_termination_signal() first");
291 let app = App::new(Arc::clone(&self.state), terminated_rx, self.interrupt_tx);
292 app.run().await
293 }
294 }
295
296 /// Runs the TUI in HTTP/WS RPC client mode.
297 async fn run_rpc_client_mode(self, client: RpcClient) -> Result<()> {
298 let (terminated_tx, terminated_rx) = watch::channel(false);
299
300 // Spawn the RPC bridge as a background task
301 let bridge_state = Arc::clone(&self.state);
302 let cancel_rx = terminated_rx.clone();
303 let bridge_handle = tokio::spawn(async move {
304 if let Err(e) = rpc_bridge::run_rpc_bridge(client, bridge_state, cancel_rx).await {
305 tracing::error!(error = %e, "RPC bridge exited with error");
306 }
307 });
308
309 info!("TUI running in RPC client mode");
310
311 // Run the TUI render/input loop
312 let app = App::new(Arc::clone(&self.state), terminated_rx, self.interrupt_tx);
313 let result = app.run().await;
314
315 // Signal the bridge to stop and wait for it
316 let _ = terminated_tx.send(true);
317 let _ = bridge_handle.await;
318
319 result
320 }
321
322 /// Runs the TUI in subprocess RPC mode.
323 async fn run_subprocess_mode(self, subprocess: SubprocessConfig) -> Result<()> {
324 let SubprocessConfig {
325 mut child,
326 stdin,
327 stdout,
328 } = subprocess;
329
330 // Create termination signal
331 let (terminated_tx, terminated_rx) = watch::channel(false);
332
333 // Create RPC writer for sending commands
334 let rpc_writer = RpcWriter::new(stdin);
335
336 // Spawn the event reader as a background task
337 let reader_state = Arc::clone(&self.state);
338 let cancel_rx = terminated_rx.clone();
339 let reader_handle = tokio::spawn(async move {
340 rpc_source::run_rpc_event_reader(stdout, reader_state, cancel_rx).await;
341 });
342
343 info!("TUI running in subprocess RPC mode");
344
345 // Run the TUI render/input loop with subprocess support
346 let app = App::new_subprocess(Arc::clone(&self.state), terminated_rx, rpc_writer.clone());
347 let result = app.run().await;
348
349 // Signal cancellation
350 let _ = terminated_tx.send(true);
351
352 // Send abort to subprocess and close stdin
353 let _ = rpc_writer.send_abort().await;
354 let _ = rpc_writer.close().await;
355
356 // Wait for reader to finish
357 let _ = reader_handle.await;
358
359 // Wait for subprocess to exit
360 let _ = child.wait().await;
361
362 result
363 }
364}
365
366impl Default for Tui {
367 fn default() -> Self {
368 Self::new()
369 }
370}