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 clipboard;
43pub mod command;
44pub(crate) mod delights;
45pub mod error;
46pub mod event;
47pub mod file_picker;
48pub mod highlight;
49pub mod hyperlink;
50pub mod layout;
51pub mod metrics;
52pub mod render_cache;
53pub(crate) mod session;
54#[cfg(test)]
55pub mod test_utils;
56pub mod theme;
57pub mod types;
58pub mod widgets;
59
60use std::io;
61
62pub use app::App;
63pub use channel::TuiChannel;
64pub use command::TuiCommand;
65pub use error::TuiError;
66pub use event::{AgentEvent, AppEvent, CrosstermEventSource, EventReader, EventSource};
67pub use metrics::{MetricsCollector, MetricsSnapshot};
68use ratatui::Terminal;
69use ratatui::backend::CrosstermBackend;
70use tokio::sync::mpsc;
71pub use types::{ChatMessage, InputMode, MessageRole, PasteState};
72
73/// Run the TUI dashboard until the user quits.
74///
75/// Initialises the terminal in raw/alternate-screen mode, drives the render
76/// loop, and restores the terminal on normal exit, error, **and** panic.
77///
78/// # Arguments
79///
80/// * `app` — fully-constructed [`App`] instance (see [`App::new`]).
81/// * `event_rx` — receiver end of the [`AppEvent`] channel produced by
82///   [`EventReader`].
83///
84/// # Errors
85///
86/// Returns [`TuiError`] if terminal initialisation, rendering, or restoration
87/// fails.
88///
89/// # Examples
90///
91/// ```rust,no_run
92/// use tokio::sync::mpsc;
93/// use zeph_tui::{App, run_tui};
94///
95/// #[tokio::main]
96/// async fn main() -> Result<(), zeph_tui::TuiError> {
97///     let (user_tx, user_rx) = mpsc::channel(64);
98///     let (_agent_tx, agent_rx) = mpsc::channel(64);
99///     let app = App::new(user_tx, agent_rx);
100///     let (_event_tx, event_rx) = mpsc::channel(64);
101///     run_tui(app, event_rx).await
102/// }
103/// ```
104#[cfg_attr(
105    feature = "profiling",
106    tracing::instrument(name = "tui.lib.run_tui", skip_all)
107)]
108pub async fn run_tui(mut app: App, mut event_rx: mpsc::Receiver<AppEvent>) -> Result<(), TuiError> {
109    let original_hook = std::panic::take_hook();
110    std::panic::set_hook(Box::new(move |info| {
111        let _ = crossterm::terminal::disable_raw_mode();
112        let _ = crossterm::execute!(
113            io::stdout(),
114            crossterm::terminal::LeaveAlternateScreen,
115            crossterm::event::DisableBracketedPaste,
116            crossterm::event::DisableMouseCapture,
117        );
118        // Disable alternate-scroll mode; write directly to stderr to avoid
119        // interfering with the already-corrupted stdout alternate screen.
120        let _ = std::io::Write::write_all(&mut io::stderr(), b"\x1b[?1007l");
121        original_hook(info);
122    }));
123
124    let mut terminal = init_terminal()?;
125
126    let result = tui_loop(&mut app, &mut event_rx, &mut terminal).await;
127
128    restore_terminal(&mut terminal)?;
129
130    // Restore the default panic hook
131    let _ = std::panic::take_hook();
132
133    result
134}
135
136/// Tracks how much of the UI needs to be redrawn after each event.
137///
138/// The render loop inspects this after every `select!` arm to decide whether
139/// to call `terminal.draw()` and, if so, how eagerly.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141enum DirtyState {
142    /// Nothing changed — skip `terminal.draw()` entirely.
143    Clean,
144    /// Only the spinner / progress indicator may have advanced (tick event).
145    /// Draw only when the agent is actively running so the spinner animates.
146    AnimationOnly,
147    /// Layout, content, or input changed — always redraw.
148    Full,
149}
150
151/// Maximum agent events drained per render-loop iteration.
152///
153/// Bounds how long a streaming burst can run before the loop repaints and
154/// services the animation/input arms again. Sized well above a typical
155/// per-frame chunk count so normal streaming drains fully in one pass.
156const AGENT_DRAIN_BATCH: u16 = 64;
157
158#[cfg_attr(
159    feature = "profiling",
160    tracing::instrument(name = "tui.lib.tui_loop", skip_all)
161)]
162async fn tui_loop(
163    app: &mut App,
164    event_rx: &mut mpsc::Receiver<AppEvent>,
165    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
166) -> Result<(), TuiError> {
167    // 100 ms ≈ 10 fps animation heartbeat, matching the EventReader tick rate.
168    let mut tick = tokio::time::interval(std::time::Duration::from_millis(100));
169    tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
170    let mut dirty = DirtyState::Clean;
171    let mut first_draw_done = false;
172
173    loop {
174        tokio::select! {
175            biased;
176            Some(event) = event_rx.recv() => {
177                app.handle_event(event);
178                dirty = DirtyState::Full;
179            }
180            agent_poll = app.poll_agent_event() => {
181                if let Some(agent_event) = agent_poll {
182                    app.handle_agent_event(agent_event);
183                    // Drain a bounded batch per iteration. An unbounded drain lets a
184                    // fast LLM stream monopolise the loop: tokens only repaint once the
185                    // whole backlog is consumed (looks frozen, then jumps) and the wave
186                    // tick (event_rx arm) is starved meanwhile. Capping returns control
187                    // to `select!` regularly so streaming repaints smoothly and the
188                    // animation clock keeps ticking.
189                    let mut drained = 0u16;
190                    while drained < AGENT_DRAIN_BATCH {
191                        match app.try_recv_agent_event() {
192                            Ok(ev) => {
193                                app.handle_agent_event(ev);
194                                drained += 1;
195                            }
196                            Err(_) => break,
197                        }
198                    }
199                } else {
200                    // Agent channel closed: agent exited. Quit the TUI.
201                    app.should_quit = true;
202                }
203                dirty = DirtyState::Full;
204            }
205            _ = tick.tick() => {
206                // The internal interval is an animation heartbeat independent of the
207                // EventReader's `AppEvent::Tick`s, so the wave keeps moving even when
208                // the event channel is briefly starved by a streaming burst.
209                app.advance_wave_tick();
210                if dirty == DirtyState::Clean {
211                    dirty = DirtyState::AnimationOnly;
212                }
213            }
214        }
215
216        // C2: drain pending mouse capture requests in the post-select block,
217        // never inside an event arm, to avoid ordering hazards.
218        if let Some(enable) = app.take_mouse_capture_request() {
219            let stdout = terminal.backend_mut();
220            if enable {
221                // Switching to mouse capture: disable alternate scroll first so
222                // wheel events are forwarded as MouseEvent rather than arrow keys.
223                let _ = crossterm::execute!(
224                    stdout,
225                    DisableAlternateScroll,
226                    crossterm::event::EnableMouseCapture,
227                );
228            } else {
229                let _ = crossterm::execute!(
230                    stdout,
231                    crossterm::event::DisableMouseCapture,
232                    EnableAlternateScroll,
233                );
234            }
235        }
236
237        app.poll_metrics();
238        app.poll_pending_file_index();
239        app.poll_pending_transcript();
240        app.poll_pending_theme();
241        app.refresh_task_snapshots();
242
243        let should_draw = match dirty {
244            DirtyState::Clean => false,
245            // Animate while the agent is busy OR background/external requests are
246            // inflight, so the violet Network wave keeps moving even when the agent
247            // itself is idle.
248            DirtyState::AnimationOnly => app.is_agent_busy() || app.background_inflight() > 0,
249            DirtyState::Full => true,
250        };
251
252        if should_draw {
253            terminal.draw(|frame| app.draw(frame))?;
254            let links = app.take_hyperlinks();
255            if !links.is_empty() {
256                hyperlink::write_osc8(terminal.backend_mut(), &links)?;
257            }
258            dirty = DirtyState::Clean;
259
260            // C3: enable mouse capture after the first frame so that
261            // `last_layout` is populated before any MouseEvent arrives.
262            if !first_draw_done {
263                first_draw_done = true;
264                if app.mouse_enabled() {
265                    let stdout = terminal.backend_mut();
266                    let _ = crossterm::execute!(
267                        stdout,
268                        DisableAlternateScroll,
269                        crossterm::event::EnableMouseCapture,
270                    );
271                }
272            }
273        }
274
275        if app.should_quit {
276            break;
277        }
278    }
279    Ok(())
280}
281
282/// Enables alternate-scroll mode (`\x1b[?1007h`).
283///
284/// In this mode the terminal forwards scroll-wheel events as `Up`/`Down` arrow
285/// key sequences instead of mouse events, allowing native text selection without
286/// holding Shift.
287struct EnableAlternateScroll;
288
289impl crossterm::Command for EnableAlternateScroll {
290    fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
291        f.write_str("\x1b[?1007h")
292    }
293
294    #[cfg(windows)]
295    fn execute_winapi(&self) -> std::io::Result<()> {
296        Ok(())
297    }
298}
299
300/// Disables alternate-scroll mode (`\x1b[?1007l`).
301struct DisableAlternateScroll;
302
303impl crossterm::Command for DisableAlternateScroll {
304    fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
305        f.write_str("\x1b[?1007l")
306    }
307
308    #[cfg(windows)]
309    fn execute_winapi(&self) -> std::io::Result<()> {
310        Ok(())
311    }
312}
313
314fn init_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>, TuiError> {
315    crossterm::terminal::enable_raw_mode()?;
316    let mut stdout = io::stdout();
317    crossterm::execute!(
318        stdout,
319        crossterm::terminal::EnterAlternateScreen,
320        EnableAlternateScroll,
321        crossterm::event::EnableBracketedPaste,
322    )?;
323    let backend = CrosstermBackend::new(stdout);
324    Ok(Terminal::new(backend)?)
325}
326
327fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<(), TuiError> {
328    crossterm::terminal::disable_raw_mode()?;
329    crossterm::execute!(
330        terminal.backend_mut(),
331        crossterm::terminal::LeaveAlternateScreen,
332        DisableAlternateScroll,
333        crossterm::event::DisableBracketedPaste,
334        crossterm::event::DisableMouseCapture,
335    )?;
336    terminal.show_cursor()?;
337    Ok(())
338}
339
340#[cfg(test)]
341mod tests {
342    use tokio::sync::mpsc;
343
344    use crate::app::App;
345    use crate::metrics::MetricsSnapshot;
346
347    fn make_app() -> App {
348        let (user_tx, _user_rx) = mpsc::channel(1);
349        let (_agent_tx, agent_rx) = mpsc::channel(1);
350        App::new(user_tx, agent_rx)
351    }
352
353    /// Regression test for #1077: the `tui_loop` must redraw on every tick even with no
354    /// user/agent events. Before the fix the tick arm was `_ = tick.tick() => {}` (no-op),
355    /// so the loop stalled after the first frame. The fix moves the draw call to the top of
356    /// each loop iteration, making it unconditional.
357    ///
358    /// This test verifies the observable consequence: `App::poll_metrics()` can be called
359    /// repeatedly without side-effects, and the `MetricsSnapshot` is populated from the
360    /// collector on each call — confirming the contract the fixed loop relies on.
361    #[test]
362    fn tick_arm_sets_dirty() {
363        let mut app = make_app();
364        // Simulate what the fixed loop does: poll_metrics on each iteration.
365        app.poll_metrics();
366        app.poll_metrics();
367        // If poll_metrics panics or the metrics watch channel is broken the test fails.
368        // Verify the snapshot is accessible after polling.
369        let _: &MetricsSnapshot = &app.metrics;
370    }
371
372    #[test]
373    fn alternate_scroll_enable_ansi() {
374        use crate::EnableAlternateScroll;
375        use crossterm::Command as _;
376        let mut buf = String::new();
377        EnableAlternateScroll.write_ansi(&mut buf).unwrap();
378        assert_eq!(buf, "\x1b[?1007h");
379    }
380
381    #[test]
382    fn alternate_scroll_disable_ansi() {
383        use crate::DisableAlternateScroll;
384        use crossterm::Command as _;
385        let mut buf = String::new();
386        DisableAlternateScroll.write_ansi(&mut buf).unwrap();
387        assert_eq!(buf, "\x1b[?1007l");
388    }
389}