Skip to main content

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}