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};
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::Paste(text)) => Some(AppEvent::Paste(text)),
79                _ => Some(AppEvent::Tick),
80            }
81        } else {
82            Some(AppEvent::Tick)
83        }
84    }
85}
86
87/// Top-level event consumed by the [`crate::App`] event handler.
88///
89/// Events arrive from two sources:
90/// - Terminal input via [`EventReader`] / [`CrosstermEventSource`].
91/// - Agent output forwarded through [`AgentEvent`] by [`crate::TuiChannel`].
92///
93/// # Examples
94///
95/// ```rust
96/// use zeph_tui::event::AppEvent;
97///
98/// let ev = AppEvent::Tick;
99/// assert!(matches!(ev, AppEvent::Tick));
100/// ```
101#[non_exhaustive]
102#[derive(Debug)]
103pub enum AppEvent {
104    /// A keyboard event from crossterm.
105    Key(KeyEvent),
106    /// Periodic tick used to drive animations and idle redraws.
107    Tick,
108    /// The terminal was resized to the given `(columns, rows)`.
109    Resize(u16, u16),
110    /// An event forwarded from the agent event channel.
111    Agent(AgentEvent),
112    /// Text pasted via bracketed paste mode.
113    ///
114    /// The string may contain `\n` characters (multiline paste). The app
115    /// inserts it verbatim into the input buffer; Enter is still required
116    /// to submit (matching vim/neovim behaviour).
117    Paste(String),
118}
119
120/// Events produced by the agent and forwarded to the TUI via [`crate::TuiChannel`].
121///
122/// Each variant corresponds to a distinct phase or signal in the agent lifecycle
123/// (streaming output, tool execution, user confirmation, etc.).
124///
125/// # Examples
126///
127/// ```rust
128/// use zeph_tui::event::AgentEvent;
129///
130/// let ev = AgentEvent::Chunk("partial response".to_string());
131/// assert!(matches!(ev, AgentEvent::Chunk(_)));
132/// ```
133#[non_exhaustive]
134#[derive(Debug)]
135pub enum AgentEvent {
136    /// A streaming text chunk from the LLM — appended to the current message.
137    Chunk(String),
138    /// A complete (non-streaming) assistant message.
139    FullMessage(String),
140    /// Signals that streaming is complete; the chat widget stops the cursor.
141    Flush,
142    /// The agent is waiting for an LLM response (drives the throbber).
143    Typing,
144    /// A short status string to display in the activity bar (e.g. `"Searching memory…"`).
145    Status(String),
146    /// A tool call has started; the TUI should display a spinner with the tool name.
147    ToolStart {
148        /// Canonical tool name (e.g. `"bash"`, `"read_file"`).
149        tool_name: zeph_common::ToolName,
150        /// The primary command or argument string shown in the status bar.
151        command: String,
152        /// Opaque tool-call identifier for correlating subsequent events.
153        tool_call_id: String,
154    },
155    /// An incremental output chunk from a long-running tool (e.g. streaming shell output).
156    ToolOutputChunk {
157        /// Tool that produced the chunk.
158        tool_name: zeph_common::ToolName,
159        /// Command argument associated with the tool call.
160        command: String,
161        /// The chunk text to append.
162        chunk: String,
163        /// Opaque tool-call identifier for id-based message lookup.
164        tool_call_id: String,
165    },
166    /// Final tool output, replacing any in-progress chunks for this call.
167    ToolOutput {
168        /// Tool that produced the output.
169        tool_name: zeph_common::ToolName,
170        /// Command argument associated with the tool call.
171        command: String,
172        /// Full rendered output body.
173        output: String,
174        /// `true` if the tool succeeded, `false` on error.
175        success: bool,
176        /// Optional diff to display inline in the chat.
177        diff: Option<zeph_core::DiffData>,
178        /// Human-readable filter summary, if output was filtered.
179        filter_stats: Option<String>,
180        /// Indices of lines retained by the filter.
181        kept_lines: Option<Vec<usize>>,
182        /// Opaque tool-call identifier for id-based message lookup.
183        tool_call_id: String,
184    },
185    /// The agent requests a boolean confirmation from the user.
186    ConfirmRequest {
187        /// Prompt text shown in the confirmation dialog.
188        prompt: String,
189        /// One-shot channel to send the user's `true`/`false` response.
190        response_tx: oneshot::Sender<bool>,
191    },
192    /// The agent requests structured input via an elicitation dialog.
193    ElicitationRequest {
194        /// The elicitation schema and prompt.
195        request: zeph_core::channel::ElicitationRequest,
196        /// One-shot channel to send the user's response.
197        response_tx: oneshot::Sender<zeph_core::channel::ElicitationResponse>,
198    },
199    /// Updated count of messages queued for the agent (shown in the input bar).
200    QueueCount(usize),
201    /// A diff is ready for immediate display in the diff panel.
202    DiffReady {
203        /// The diff payload to attach to the corresponding tool message.
204        diff: zeph_core::DiffData,
205        /// Identifies which tool call produced this diff.
206        tool_call_id: String,
207    },
208    /// Result from a slash-command dispatched to the agent.
209    CommandResult {
210        /// The slash-command identifier that produced this result.
211        command_id: String,
212        /// Formatted command output to display.
213        output: String,
214    },
215    /// Wire a cancel signal into the TUI App after early startup (Phase 2).
216    SetCancelSignal(Arc<Notify>),
217    /// Wire a metrics receiver into the TUI App after early startup (Phase 2).
218    SetMetricsRx(watch::Receiver<MetricsSnapshot>),
219    /// A foreground subagent has been spawned; the TUI should switch view to its transcript.
220    ForegroundSubagentStarted {
221        /// Stable sub-agent identifier (`task_id` from `SubAgentManager`).
222        id: String,
223        /// Human-readable agent definition name.
224        name: String,
225    },
226    /// A foreground subagent has reached a terminal state; the TUI should return to Main view.
227    ForegroundSubagentCompleted {
228        /// Stable sub-agent identifier.
229        id: String,
230        /// Human-readable agent definition name.
231        name: String,
232        /// `true` if Completed state, `false` if Failed/Canceled.
233        success: bool,
234    },
235    /// Current context token count estimate, updated after each context assembly.
236    ///
237    /// The value is an approximation based on character-level heuristics and may
238    /// diverge slightly from the actual token count sent to the LLM. Stale between
239    /// turns (the previous turn's estimate remains displayed until the next assembly).
240    ContextEstimate(usize),
241    /// Updated fleet snapshot from the background DB poll task (#3884).
242    FleetSnapshot(crate::widgets::fleet::FleetSnapshot),
243}
244
245/// Blocking event pump that forwards terminal events to the async [`AppEvent`] channel.
246///
247/// `EventReader` must run on a **dedicated `std::thread`** — it calls
248/// `blocking_send` and crossterm's blocking poll, which would stall a tokio
249/// worker thread.
250///
251/// # Examples
252///
253/// ```rust,no_run
254/// use std::time::Duration;
255/// use tokio::sync::mpsc;
256/// use zeph_tui::EventReader;
257///
258/// let (tx, rx) = mpsc::channel(64);
259/// let reader = EventReader::new(tx, Duration::from_millis(250));
260/// std::thread::spawn(|| reader.run());
261/// ```
262pub struct EventReader {
263    tx: mpsc::Sender<AppEvent>,
264    tick_rate: Duration,
265}
266
267impl EventReader {
268    /// Create a new reader that sends events to `tx` at up to `tick_rate` cadence.
269    ///
270    /// # Examples
271    ///
272    /// ```rust
273    /// use std::time::Duration;
274    /// use tokio::sync::mpsc;
275    /// use zeph_tui::EventReader;
276    ///
277    /// let (tx, _rx) = mpsc::channel(64);
278    /// let reader = EventReader::new(tx, Duration::from_millis(250));
279    /// ```
280    #[must_use]
281    pub fn new(tx: mpsc::Sender<AppEvent>, tick_rate: Duration) -> Self {
282        Self { tx, tick_rate }
283    }
284
285    /// Start the blocking event loop using the default [`CrosstermEventSource`].
286    ///
287    /// **Must be called from a dedicated `std::thread`**, not a tokio worker.
288    /// Returns when the [`AppEvent`] channel receiver is dropped.
289    pub fn run(self) {
290        let tick_rate = self.tick_rate;
291        self.run_with_source(CrosstermEventSource::new(tick_rate));
292    }
293
294    /// Start the blocking event loop with a custom [`EventSource`].
295    ///
296    /// This variant exists primarily for testing with mock sources.
297    /// Returns when the source returns `None` or the channel is closed.
298    pub fn run_with_source(self, mut source: impl EventSource) {
299        while let Some(evt) = source.next_event() {
300            if self.tx.blocking_send(evt).is_err() {
301                break;
302            }
303        }
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn agent_event_debug() {
313        let e = AgentEvent::Chunk("hello".into());
314        let s = format!("{e:?}");
315        assert!(s.contains("Chunk"));
316    }
317
318    #[test]
319    fn app_event_variants() {
320        let tick = AppEvent::Tick;
321        assert!(matches!(tick, AppEvent::Tick));
322
323        let resize = AppEvent::Resize(80, 24);
324        assert!(matches!(resize, AppEvent::Resize(80, 24)));
325    }
326
327    #[test]
328    fn event_reader_construction() {
329        let (tx, _rx) = mpsc::channel(16);
330        let reader = EventReader::new(tx, Duration::from_millis(100));
331        assert_eq!(reader.tick_rate, Duration::from_millis(100));
332    }
333
334    #[test]
335    fn confirm_request_debug() {
336        let (tx, _rx) = oneshot::channel();
337        let e = AgentEvent::ConfirmRequest {
338            prompt: "delete?".into(),
339            response_tx: tx,
340        };
341        let s = format!("{e:?}");
342        assert!(s.contains("ConfirmRequest"));
343        assert!(s.contains("delete?"));
344    }
345
346    #[test]
347    fn app_event_paste_variant() {
348        assert!(matches!(AppEvent::Paste("x".into()), AppEvent::Paste(_)));
349    }
350}