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//!
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}