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}