zeph_tui/lib.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! # zeph-tui
5//!
6//! Ratatui-based TUI dashboard for the Zeph AI agent with real-time metrics,
7//! syntax-highlighted chat, tool-output diffs, command palette, file picker,
8//! and multi-panel layout.
9//!
10//! ## Architecture
11//!
12//! The crate is structured around a central [`App`] state machine that owns all
13//! widget state and reacts to two event streams:
14//!
15//! - [`AppEvent`] — keyboard, resize, and mouse events produced by
16//! [`EventReader`] running on a dedicated OS thread.
17//! - [`AgentEvent`] — streaming agent output, tool events, and control signals
18//! forwarded through [`TuiChannel`].
19//!
20//! The main entry point is [`run_tui`], which initialises the terminal,
21//! drives the render loop, and restores the terminal on exit or panic.
22//!
23//! ## Quick start
24//!
25//! ```rust,no_run
26//! use tokio::sync::mpsc;
27//! use zeph_tui::{App, run_tui};
28//! use zeph_tui::event::AppEvent;
29//!
30//! #[tokio::main]
31//! async fn main() -> Result<(), zeph_tui::TuiError> {
32//! let (user_tx, user_rx) = mpsc::channel(64);
33//! let (_agent_tx, agent_rx) = mpsc::channel(64);
34//! let app = App::new(user_tx, agent_rx);
35//! let (_event_tx, event_rx) = mpsc::channel(64);
36//! run_tui(app, event_rx).await
37//! }
38//! ```
39
40pub mod app;
41pub mod channel;
42pub mod command;
43pub mod error;
44pub mod event;
45pub mod file_picker;
46pub mod highlight;
47pub mod hyperlink;
48pub mod layout;
49pub mod metrics;
50pub mod render_cache;
51pub(crate) mod session;
52#[cfg(test)]
53pub mod test_utils;
54pub mod theme;
55pub mod types;
56pub mod widgets;
57
58use std::io;
59
60pub use app::App;
61pub use channel::TuiChannel;
62pub use command::TuiCommand;
63pub use error::TuiError;
64pub use event::{AgentEvent, AppEvent, CrosstermEventSource, EventReader, EventSource};
65pub use metrics::{MetricsCollector, MetricsSnapshot};
66use ratatui::Terminal;
67use ratatui::backend::CrosstermBackend;
68use tokio::sync::mpsc;
69pub use types::{ChatMessage, InputMode, MessageRole, PasteState};
70
71/// Run the TUI dashboard until the user quits.
72///
73/// Initialises the terminal in raw/alternate-screen mode, drives the render
74/// loop, and restores the terminal on normal exit, error, **and** panic.
75///
76/// # Arguments
77///
78/// * `app` — fully-constructed [`App`] instance (see [`App::new`]).
79/// * `event_rx` — receiver end of the [`AppEvent`] channel produced by
80/// [`EventReader`].
81///
82/// # Errors
83///
84/// Returns [`TuiError`] if terminal initialisation, rendering, or restoration
85/// fails.
86///
87/// # Examples
88///
89/// ```rust,no_run
90/// use tokio::sync::mpsc;
91/// use zeph_tui::{App, run_tui};
92///
93/// #[tokio::main]
94/// async fn main() -> Result<(), zeph_tui::TuiError> {
95/// let (user_tx, user_rx) = mpsc::channel(64);
96/// let (_agent_tx, agent_rx) = mpsc::channel(64);
97/// let app = App::new(user_tx, agent_rx);
98/// let (_event_tx, event_rx) = mpsc::channel(64);
99/// run_tui(app, event_rx).await
100/// }
101/// ```
102pub async fn run_tui(mut app: App, mut event_rx: mpsc::Receiver<AppEvent>) -> Result<(), TuiError> {
103 let original_hook = std::panic::take_hook();
104 std::panic::set_hook(Box::new(move |info| {
105 let _ = crossterm::terminal::disable_raw_mode();
106 let _ = crossterm::execute!(
107 io::stdout(),
108 crossterm::terminal::LeaveAlternateScreen,
109 crossterm::event::DisableMouseCapture,
110 crossterm::event::DisableBracketedPaste,
111 );
112 original_hook(info);
113 }));
114
115 let mut terminal = init_terminal()?;
116
117 let result = tui_loop(&mut app, &mut event_rx, &mut terminal).await;
118
119 restore_terminal(&mut terminal)?;
120
121 // Restore the default panic hook
122 let _ = std::panic::take_hook();
123
124 result
125}
126
127/// Tracks how much of the UI needs to be redrawn after each event.
128///
129/// The render loop inspects this after every `select!` arm to decide whether
130/// to call `terminal.draw()` and, if so, how eagerly.
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132enum DirtyState {
133 /// Nothing changed — skip `terminal.draw()` entirely.
134 Clean,
135 /// Only the spinner / progress indicator may have advanced (tick event).
136 /// Draw only when the agent is actively running so the spinner animates.
137 AnimationOnly,
138 /// Layout, content, or input changed — always redraw.
139 Full,
140}
141
142async fn tui_loop(
143 app: &mut App,
144 event_rx: &mut mpsc::Receiver<AppEvent>,
145 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
146) -> Result<(), TuiError> {
147 let mut tick = tokio::time::interval(std::time::Duration::from_millis(250));
148 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
149 let mut dirty = DirtyState::Clean;
150
151 loop {
152 tokio::select! {
153 biased;
154 Some(event) = event_rx.recv() => {
155 app.handle_event(event);
156 dirty = DirtyState::Full;
157 }
158 agent_poll = app.poll_agent_event() => {
159 if let Some(agent_event) = agent_poll {
160 app.handle_agent_event(agent_event);
161 while let Ok(ev) = app.try_recv_agent_event() {
162 app.handle_agent_event(ev);
163 }
164 } else {
165 // Agent channel closed: agent exited. Quit the TUI.
166 app.should_quit = true;
167 }
168 dirty = DirtyState::Full;
169 }
170 _ = tick.tick() => {
171 // Tick: only upgrade to AnimationOnly if no full redraw is
172 // already scheduled, so a burst of agent events is not
173 // downgraded.
174 if dirty == DirtyState::Clean {
175 dirty = DirtyState::AnimationOnly;
176 }
177 }
178 }
179
180 app.poll_metrics();
181 app.poll_pending_file_index();
182 app.poll_pending_transcript();
183 app.refresh_task_snapshots();
184
185 let should_draw = match dirty {
186 DirtyState::Clean => false,
187 DirtyState::AnimationOnly => app.is_agent_busy(),
188 DirtyState::Full => true,
189 };
190
191 if should_draw {
192 terminal.draw(|frame| app.draw(frame))?;
193 let links = app.take_hyperlinks();
194 if !links.is_empty() {
195 hyperlink::write_osc8(terminal.backend_mut(), &links)?;
196 }
197 dirty = DirtyState::Clean;
198 }
199
200 if app.should_quit {
201 break;
202 }
203 }
204 Ok(())
205}
206
207fn init_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>, TuiError> {
208 crossterm::terminal::enable_raw_mode()?;
209 let mut stdout = io::stdout();
210 crossterm::execute!(
211 stdout,
212 crossterm::terminal::EnterAlternateScreen,
213 crossterm::event::EnableMouseCapture,
214 crossterm::event::EnableBracketedPaste,
215 )?;
216 let backend = CrosstermBackend::new(stdout);
217 Ok(Terminal::new(backend)?)
218}
219
220fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<(), TuiError> {
221 crossterm::terminal::disable_raw_mode()?;
222 crossterm::execute!(
223 terminal.backend_mut(),
224 crossterm::terminal::LeaveAlternateScreen,
225 crossterm::event::DisableMouseCapture,
226 crossterm::event::DisableBracketedPaste,
227 )?;
228 terminal.show_cursor()?;
229 Ok(())
230}
231
232#[cfg(test)]
233mod tests {
234 use tokio::sync::mpsc;
235
236 use crate::app::App;
237 use crate::metrics::MetricsSnapshot;
238
239 fn make_app() -> App {
240 let (user_tx, _user_rx) = mpsc::channel(1);
241 let (_agent_tx, agent_rx) = mpsc::channel(1);
242 App::new(user_tx, agent_rx)
243 }
244
245 /// Regression test for #1077: the `tui_loop` must redraw on every tick even with no
246 /// user/agent events. Before the fix the tick arm was `_ = tick.tick() => {}` (no-op),
247 /// so the loop stalled after the first frame. The fix moves the draw call to the top of
248 /// each loop iteration, making it unconditional.
249 ///
250 /// This test verifies the observable consequence: `App::poll_metrics()` can be called
251 /// repeatedly without side-effects, and the `MetricsSnapshot` is populated from the
252 /// collector on each call — confirming the contract the fixed loop relies on.
253 #[test]
254 fn tick_arm_sets_dirty() {
255 let mut app = make_app();
256 // Simulate what the fixed loop does: poll_metrics on each iteration.
257 app.poll_metrics();
258 app.poll_metrics();
259 // If poll_metrics panics or the metrics watch channel is broken the test fails.
260 // Verify the snapshot is accessible after polling.
261 let _: &MetricsSnapshot = &app.metrics;
262 }
263}