Skip to main content

zeph_tui/
event.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::sync::Arc;
5use std::time::Duration;
6
7use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEventKind};
8use tokio::sync::{Notify, mpsc, oneshot, watch};
9
10use zeph_core::metrics::MetricsSnapshot;
11
12/// Source of raw terminal events consumed by [`EventReader`].
13///
14/// Implement this trait to provide a custom event source (e.g. a mock for
15/// testing or a replay driver).
16///
17/// # Examples
18///
19/// ```rust
20/// use zeph_tui::event::{AppEvent, EventSource};
21/// use crossterm::event::KeyEvent;
22///
23/// struct OneTickSource;
24///
25/// impl EventSource for OneTickSource {
26///     fn next_event(&mut self) -> Option<AppEvent> {
27///         None // signal EOF
28///     }
29/// }
30/// ```
31pub trait EventSource: Send + 'static {
32    /// Return the next event, or `None` to signal that the source is exhausted
33    /// and the event loop should terminate.
34    fn next_event(&mut self) -> Option<AppEvent>;
35}
36
37/// [`EventSource`] backed by crossterm's blocking event poll.
38///
39/// Polls for terminal events up to `tick_rate` before returning a
40/// [`AppEvent::Tick`] if no event arrived. This drives the TUI's animation
41/// and idle redraw cadence.
42///
43/// # Examples
44///
45/// ```rust
46/// use std::time::Duration;
47/// use zeph_tui::CrosstermEventSource;
48///
49/// let source = CrosstermEventSource::new(Duration::from_millis(250));
50/// ```
51pub struct CrosstermEventSource {
52    tick_rate: Duration,
53}
54
55impl CrosstermEventSource {
56    /// Create a new source with the given poll interval.
57    ///
58    /// # Examples
59    ///
60    /// ```rust
61    /// use std::time::Duration;
62    /// use zeph_tui::CrosstermEventSource;
63    ///
64    /// let src = CrosstermEventSource::new(Duration::from_millis(100));
65    /// ```
66    #[must_use]
67    pub fn new(tick_rate: Duration) -> Self {
68        Self { tick_rate }
69    }
70}
71
72impl EventSource for CrosstermEventSource {
73    fn next_event(&mut self) -> Option<AppEvent> {
74        if event::poll(self.tick_rate).unwrap_or(false) {
75            match event::read() {
76                Ok(CrosstermEvent::Key(key)) => Some(AppEvent::Key(key)),
77                Ok(CrosstermEvent::Resize(w, h)) => Some(AppEvent::Resize(w, h)),
78                Ok(CrosstermEvent::Mouse(mouse)) => match mouse.kind {
79                    MouseEventKind::ScrollUp => Some(AppEvent::MouseScroll(1)),
80                    MouseEventKind::ScrollDown => Some(AppEvent::MouseScroll(-1)),
81                    _ => Some(AppEvent::Tick),
82                },
83                Ok(CrosstermEvent::Paste(text)) => Some(AppEvent::Paste(text)),
84                _ => Some(AppEvent::Tick),
85            }
86        } else {
87            Some(AppEvent::Tick)
88        }
89    }
90}
91
92/// Top-level event consumed by the [`crate::App`] event handler.
93///
94/// Events arrive from two sources:
95/// - Terminal input via [`EventReader`] / [`CrosstermEventSource`].
96/// - Agent output forwarded through [`AgentEvent`] by [`crate::TuiChannel`].
97///
98/// # Examples
99///
100/// ```rust
101/// use zeph_tui::event::AppEvent;
102///
103/// let ev = AppEvent::Tick;
104/// assert!(matches!(ev, AppEvent::Tick));
105/// ```
106#[derive(Debug)]
107pub enum AppEvent {
108    /// A keyboard event from crossterm.
109    Key(KeyEvent),
110    /// Periodic tick used to drive animations and idle redraws.
111    Tick,
112    /// The terminal was resized to the given `(columns, rows)`.
113    Resize(u16, u16),
114    /// Mouse wheel scroll: `+1` = up, `-1` = down.
115    MouseScroll(i8),
116    /// An event forwarded from the agent event channel.
117    Agent(AgentEvent),
118    /// Text pasted via bracketed paste mode.
119    ///
120    /// The string may contain `\n` characters (multiline paste). The app
121    /// inserts it verbatim into the input buffer; Enter is still required
122    /// to submit (matching vim/neovim behaviour).
123    Paste(String),
124}
125
126/// Events produced by the agent and forwarded to the TUI via [`crate::TuiChannel`].
127///
128/// Each variant corresponds to a distinct phase or signal in the agent lifecycle
129/// (streaming output, tool execution, user confirmation, etc.).
130///
131/// # Examples
132///
133/// ```rust
134/// use zeph_tui::event::AgentEvent;
135///
136/// let ev = AgentEvent::Chunk("partial response".to_string());
137/// assert!(matches!(ev, AgentEvent::Chunk(_)));
138/// ```
139#[derive(Debug)]
140pub enum AgentEvent {
141    /// A streaming text chunk from the LLM — appended to the current message.
142    Chunk(String),
143    /// A complete (non-streaming) assistant message.
144    FullMessage(String),
145    /// Signals that streaming is complete; the chat widget stops the cursor.
146    Flush,
147    /// The agent is waiting for an LLM response (drives the throbber).
148    Typing,
149    /// A short status string to display in the activity bar (e.g. `"Searching memory…"`).
150    Status(String),
151    /// A tool call has started; the TUI should display a spinner with the tool name.
152    ToolStart {
153        /// Canonical tool name (e.g. `"bash"`, `"read_file"`).
154        tool_name: zeph_common::ToolName,
155        /// The primary command or argument string shown in the status bar.
156        command: String,
157    },
158    /// An incremental output chunk from a long-running tool (e.g. streaming shell output).
159    ToolOutputChunk {
160        /// Tool that produced the chunk.
161        tool_name: zeph_common::ToolName,
162        /// Command argument associated with the tool call.
163        command: String,
164        /// The chunk text to append.
165        chunk: String,
166    },
167    /// Final tool output, replacing any in-progress chunks for this call.
168    ToolOutput {
169        /// Tool that produced the output.
170        tool_name: zeph_common::ToolName,
171        /// Command argument associated with the tool call.
172        command: String,
173        /// Full rendered output body.
174        output: String,
175        /// `true` if the tool succeeded, `false` on error.
176        success: bool,
177        /// Optional diff to display inline in the chat.
178        diff: Option<zeph_core::DiffData>,
179        /// Human-readable filter summary, if output was filtered.
180        filter_stats: Option<String>,
181        /// Indices of lines retained by the filter.
182        kept_lines: Option<Vec<usize>>,
183    },
184    /// The agent requests a boolean confirmation from the user.
185    ConfirmRequest {
186        /// Prompt text shown in the confirmation dialog.
187        prompt: String,
188        /// One-shot channel to send the user's `true`/`false` response.
189        response_tx: oneshot::Sender<bool>,
190    },
191    /// The agent requests structured input via an elicitation dialog.
192    ElicitationRequest {
193        /// The elicitation schema and prompt.
194        request: zeph_core::channel::ElicitationRequest,
195        /// One-shot channel to send the user's response.
196        response_tx: oneshot::Sender<zeph_core::channel::ElicitationResponse>,
197    },
198    /// Updated count of messages queued for the agent (shown in the input bar).
199    QueueCount(usize),
200    /// A diff is ready for immediate display in the diff panel.
201    DiffReady(zeph_core::DiffData),
202    /// Result from a slash-command dispatched to the agent.
203    CommandResult {
204        /// The slash-command identifier that produced this result.
205        command_id: String,
206        /// Formatted command output to display.
207        output: String,
208    },
209    /// Wire a cancel signal into the TUI App after early startup (Phase 2).
210    SetCancelSignal(Arc<Notify>),
211    /// Wire a metrics receiver into the TUI App after early startup (Phase 2).
212    SetMetricsRx(watch::Receiver<MetricsSnapshot>),
213}
214
215/// Blocking event pump that forwards terminal events to the async [`AppEvent`] channel.
216///
217/// `EventReader` must run on a **dedicated `std::thread`** — it calls
218/// `blocking_send` and crossterm's blocking poll, which would stall a tokio
219/// worker thread.
220///
221/// # Examples
222///
223/// ```rust,no_run
224/// use std::time::Duration;
225/// use tokio::sync::mpsc;
226/// use zeph_tui::EventReader;
227///
228/// let (tx, rx) = mpsc::channel(64);
229/// let reader = EventReader::new(tx, Duration::from_millis(250));
230/// std::thread::spawn(|| reader.run());
231/// ```
232pub struct EventReader {
233    tx: mpsc::Sender<AppEvent>,
234    tick_rate: Duration,
235}
236
237impl EventReader {
238    /// Create a new reader that sends events to `tx` at up to `tick_rate` cadence.
239    ///
240    /// # Examples
241    ///
242    /// ```rust
243    /// use std::time::Duration;
244    /// use tokio::sync::mpsc;
245    /// use zeph_tui::EventReader;
246    ///
247    /// let (tx, _rx) = mpsc::channel(64);
248    /// let reader = EventReader::new(tx, Duration::from_millis(250));
249    /// ```
250    #[must_use]
251    pub fn new(tx: mpsc::Sender<AppEvent>, tick_rate: Duration) -> Self {
252        Self { tx, tick_rate }
253    }
254
255    /// Start the blocking event loop using the default [`CrosstermEventSource`].
256    ///
257    /// **Must be called from a dedicated `std::thread`**, not a tokio worker.
258    /// Returns when the [`AppEvent`] channel receiver is dropped.
259    pub fn run(self) {
260        let tick_rate = self.tick_rate;
261        self.run_with_source(CrosstermEventSource::new(tick_rate));
262    }
263
264    /// Start the blocking event loop with a custom [`EventSource`].
265    ///
266    /// This variant exists primarily for testing with mock sources.
267    /// Returns when the source returns `None` or the channel is closed.
268    pub fn run_with_source(self, mut source: impl EventSource) {
269        while let Some(evt) = source.next_event() {
270            if self.tx.blocking_send(evt).is_err() {
271                break;
272            }
273        }
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn agent_event_debug() {
283        let e = AgentEvent::Chunk("hello".into());
284        let s = format!("{e:?}");
285        assert!(s.contains("Chunk"));
286    }
287
288    #[test]
289    fn app_event_variants() {
290        let tick = AppEvent::Tick;
291        assert!(matches!(tick, AppEvent::Tick));
292
293        let resize = AppEvent::Resize(80, 24);
294        assert!(matches!(resize, AppEvent::Resize(80, 24)));
295    }
296
297    #[test]
298    fn event_reader_construction() {
299        let (tx, _rx) = mpsc::channel(16);
300        let reader = EventReader::new(tx, Duration::from_millis(100));
301        assert_eq!(reader.tick_rate, Duration::from_millis(100));
302    }
303
304    #[test]
305    fn confirm_request_debug() {
306        let (tx, _rx) = oneshot::channel();
307        let e = AgentEvent::ConfirmRequest {
308            prompt: "delete?".into(),
309            response_tx: tx,
310        };
311        let s = format!("{e:?}");
312        assert!(s.contains("ConfirmRequest"));
313        assert!(s.contains("delete?"));
314    }
315
316    #[test]
317    fn app_event_mouse_scroll_variant() {
318        let scroll_up = AppEvent::MouseScroll(1);
319        assert!(matches!(scroll_up, AppEvent::MouseScroll(1)));
320
321        let scroll_down = AppEvent::MouseScroll(-1);
322        assert!(matches!(scroll_down, AppEvent::MouseScroll(-1)));
323    }
324
325    #[test]
326    fn app_event_paste_variant() {
327        assert!(matches!(AppEvent::Paste("x".into()), AppEvent::Paste(_)));
328    }
329}