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}