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, MouseEvent, 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::Paste(text)) => Some(AppEvent::Paste(text)),
79                Ok(CrosstermEvent::Mouse(m)) => {
80                    // C6: filter high-frequency motion events to Tick to avoid
81                    // setting dirty=Full on every cursor move, which would
82                    // stall the render loop with unnecessary full redraws.
83                    match m.kind {
84                        MouseEventKind::Moved | MouseEventKind::Drag(_) => Some(AppEvent::Tick),
85                        _ => Some(AppEvent::Mouse(m)),
86                    }
87                }
88                _ => Some(AppEvent::Tick),
89            }
90        } else {
91            Some(AppEvent::Tick)
92        }
93    }
94}
95
96/// Top-level event consumed by the [`crate::App`] event handler.
97///
98/// Events arrive from two sources:
99/// - Terminal input via [`EventReader`] / [`CrosstermEventSource`].
100/// - Agent output forwarded through [`AgentEvent`] by [`crate::TuiChannel`].
101///
102/// # Examples
103///
104/// ```rust
105/// use zeph_tui::event::AppEvent;
106///
107/// let ev = AppEvent::Tick;
108/// assert!(matches!(ev, AppEvent::Tick));
109/// ```
110#[non_exhaustive]
111#[derive(Debug)]
112pub enum AppEvent {
113    /// A keyboard event from crossterm.
114    Key(KeyEvent),
115    /// Periodic tick used to drive animations and idle redraws.
116    Tick,
117    /// The terminal was resized to the given `(columns, rows)`.
118    Resize(u16, u16),
119    /// An event forwarded from the agent event channel.
120    Agent(AgentEvent),
121    /// Text pasted via bracketed paste mode.
122    ///
123    /// The string may contain `\n` characters (multiline paste). The app
124    /// inserts it verbatim into the input buffer; Enter is still required
125    /// to submit (matching vim/neovim behaviour).
126    Paste(String),
127    /// A mouse event from crossterm (only produced when mouse capture is enabled via
128    /// `/mouse on`). High-frequency `Moved` and `Drag` events are folded into
129    /// [`AppEvent::Tick`] by [`CrosstermEventSource`] before reaching this variant (C6).
130    Mouse(MouseEvent),
131}
132
133/// Events produced by the agent and forwarded to the TUI via [`crate::TuiChannel`].
134///
135/// Each variant corresponds to a distinct phase or signal in the agent lifecycle
136/// (streaming output, tool execution, user confirmation, etc.).
137///
138/// # Examples
139///
140/// ```rust
141/// use zeph_tui::event::AgentEvent;
142///
143/// let ev = AgentEvent::Chunk("partial response".to_string());
144/// assert!(matches!(ev, AgentEvent::Chunk(_)));
145/// ```
146#[non_exhaustive]
147#[derive(Debug)]
148pub enum AgentEvent {
149    /// A streaming text chunk from the LLM — appended to the current message.
150    Chunk(String),
151    /// A complete (non-streaming) assistant message.
152    FullMessage(String),
153    /// Signals that streaming is complete; the chat widget stops the cursor.
154    Flush,
155    /// The agent is waiting for an LLM response (drives the throbber).
156    Typing,
157    /// A short status string to display in the activity bar (e.g. `"Searching memory…"`).
158    Status(String),
159    /// A tool call has started; the TUI should display a spinner with the tool name.
160    ToolStart {
161        /// Canonical tool name (e.g. `"bash"`, `"read_file"`).
162        tool_name: zeph_common::ToolName,
163        /// The primary command or argument string shown in the status bar.
164        command: String,
165        /// Opaque tool-call identifier for correlating subsequent events.
166        tool_call_id: String,
167        /// True when this tool call originates from an MCP server rather than a native tool.
168        is_mcp: bool,
169    },
170    /// An incremental output chunk from a long-running tool (e.g. streaming shell output).
171    ToolOutputChunk {
172        /// Tool that produced the chunk.
173        tool_name: zeph_common::ToolName,
174        /// Command argument associated with the tool call.
175        command: String,
176        /// The chunk text to append.
177        chunk: String,
178        /// Opaque tool-call identifier for id-based message lookup.
179        tool_call_id: String,
180    },
181    /// Final tool output, replacing any in-progress chunks for this call.
182    ToolOutput {
183        /// Tool that produced the output.
184        tool_name: zeph_common::ToolName,
185        /// Command argument associated with the tool call.
186        command: String,
187        /// Full rendered output body.
188        output: String,
189        /// `true` if the tool succeeded, `false` on error.
190        success: bool,
191        /// Optional diff to display inline in the chat.
192        diff: Option<zeph_core::DiffData>,
193        /// Human-readable filter summary, if output was filtered.
194        filter_stats: Option<String>,
195        /// Indices of lines retained by the filter.
196        kept_lines: Option<Vec<usize>>,
197        /// Opaque tool-call identifier for id-based message lookup.
198        tool_call_id: String,
199    },
200    /// The agent requests a boolean confirmation from the user.
201    ConfirmRequest {
202        /// Prompt text shown in the confirmation dialog.
203        prompt: String,
204        /// One-shot channel to send the user's `true`/`false` response.
205        response_tx: oneshot::Sender<bool>,
206    },
207    /// The agent requests structured input via an elicitation dialog.
208    ElicitationRequest {
209        /// The elicitation schema and prompt.
210        request: zeph_core::channel::ElicitationRequest,
211        /// One-shot channel to send the user's response.
212        response_tx: oneshot::Sender<zeph_core::channel::ElicitationResponse>,
213    },
214    /// Updated count of messages queued for the agent (shown in the input bar).
215    QueueCount(usize),
216    /// A diff is ready for immediate display in the diff panel.
217    DiffReady {
218        /// The diff payload to attach to the corresponding tool message.
219        diff: zeph_core::DiffData,
220        /// Identifies which tool call produced this diff.
221        tool_call_id: String,
222    },
223    /// Result from a slash-command dispatched to the agent.
224    CommandResult {
225        /// The slash-command identifier that produced this result.
226        command_id: String,
227        /// Formatted command output to display.
228        output: String,
229    },
230    /// Wire a cancel signal into the TUI App after early startup (Phase 2).
231    SetCancelSignal(Arc<Notify>),
232    /// Wire a metrics receiver into the TUI App after early startup (Phase 2).
233    SetMetricsRx(watch::Receiver<MetricsSnapshot>),
234    /// Wire a [`zeph_common::task_supervisor::TaskSupervisor`] into the TUI App after
235    /// early startup (Phase 2), so the task registry panel reflects live task state
236    /// instead of reporting "supervisor not available".
237    SetTaskSupervisor(zeph_common::task_supervisor::TaskSupervisor),
238    /// A foreground subagent has been spawned; the TUI should switch view to its transcript.
239    ForegroundSubagentStarted {
240        /// Stable sub-agent identifier (`task_id` from `SubAgentManager`).
241        id: String,
242        /// Human-readable agent definition name.
243        name: String,
244    },
245    /// A foreground subagent has reached a terminal state; the TUI should return to Main view.
246    ForegroundSubagentCompleted {
247        /// Stable sub-agent identifier.
248        id: String,
249        /// Human-readable agent definition name.
250        name: String,
251        /// `true` if Completed state, `false` if Failed/Canceled.
252        success: bool,
253    },
254    /// A background subagent (`/agent bg`) has reached a terminal state.
255    ///
256    /// Fires for every background subagent, not just one the parent turn is blocking on.
257    /// The TUI only acts on it when `id` matches the subagent currently being viewed via the
258    /// sidebar's manual transcript view — resetting to Main and rendering a terminal marker,
259    /// since the sidebar list and the transcript reload trigger both key off
260    /// `MetricsSnapshot::sub_agents`, which no longer contains a completed agent by the time
261    /// this event is observed (#6570). Subagents not being viewed need no action here: their
262    /// completion notice is already pushed to Main chat via [`AgentEvent::FullMessage`] from
263    /// `Channel::send` in `notify_completed_subagents`.
264    BackgroundSubagentCompleted {
265        /// Stable sub-agent identifier.
266        id: String,
267        /// Human-readable agent definition name.
268        name: String,
269        /// `true` if Completed state, `false` if Failed/Canceled.
270        success: bool,
271    },
272    /// Current context token count estimate, updated after each context assembly.
273    ///
274    /// The value is an approximation based on character-level heuristics and may
275    /// diverge slightly from the actual token count sent to the LLM. Stale between
276    /// turns (the previous turn's estimate remains displayed until the next assembly).
277    ContextEstimate(usize),
278    /// Updated fleet snapshot from the background DB poll task (#3884).
279    FleetSnapshot(crate::widgets::fleet::FleetSnapshot),
280    /// Updated durable execution snapshot from the background poll task (spec-064, #4949).
281    DurableSnapshot(crate::widgets::durable::DurableSnapshot),
282    /// A non-empty prior conversation was resumed at startup (spec-068 §13.5).
283    ///
284    /// Renders as a **persistent** banner in the header/status area — unlike
285    /// [`AgentEvent::Status`], it must remain visible once the first prompt scrolls the
286    /// transient status line out of view. Never sent for a fresh (system-prompt-only)
287    /// conversation (§13.4, AC-16).
288    ResumeBanner(String),
289    /// Bounded `/history` transcript slice to backfill into the display buffer (spec-068
290    /// §13.6-§13.7).
291    ///
292    /// Pushed as distinct chat messages via `App::backfill_history_display_only`, split from
293    /// `input_history`/up-arrow recall (INV-SP-6, AC-20) — never routed through
294    /// `App::load_history`, which also feeds `input_history`.
295    HistoryBackfill(Vec<zeph_commands::TranscriptEntry>),
296    /// Full skill catalog (name + description), emitted once at agent startup and
297    /// re-emitted on skill hot-reload (spec 084 §6, issue #6648). Stored into
298    /// `App::skill_catalog` and used to refresh an open mention picker's Skills tab.
299    SkillCatalog(Arc<[zeph_core::channel::SkillCatalogItem]>),
300}
301
302/// Blocking event pump that forwards terminal events to the async [`AppEvent`] channel.
303///
304/// `EventReader` must run on a **dedicated `std::thread`** — it calls
305/// `blocking_send` and crossterm's blocking poll, which would stall a tokio
306/// worker thread.
307///
308/// # Examples
309///
310/// ```rust,no_run
311/// use std::time::Duration;
312/// use tokio::sync::mpsc;
313/// use zeph_tui::EventReader;
314///
315/// let (tx, rx) = mpsc::channel(64);
316/// let reader = EventReader::new(tx, Duration::from_millis(250));
317/// std::thread::spawn(|| reader.run());
318/// ```
319pub struct EventReader {
320    tx: mpsc::Sender<AppEvent>,
321    tick_rate: Duration,
322}
323
324impl EventReader {
325    /// Create a new reader that sends events to `tx` at up to `tick_rate` cadence.
326    ///
327    /// # Examples
328    ///
329    /// ```rust
330    /// use std::time::Duration;
331    /// use tokio::sync::mpsc;
332    /// use zeph_tui::EventReader;
333    ///
334    /// let (tx, _rx) = mpsc::channel(64);
335    /// let reader = EventReader::new(tx, Duration::from_millis(250));
336    /// ```
337    #[must_use]
338    pub fn new(tx: mpsc::Sender<AppEvent>, tick_rate: Duration) -> Self {
339        Self { tx, tick_rate }
340    }
341
342    /// Start the blocking event loop using the default [`CrosstermEventSource`].
343    ///
344    /// **Must be called from a dedicated `std::thread`**, not a tokio worker.
345    /// Returns when the [`AppEvent`] channel receiver is dropped.
346    pub fn run(self) {
347        let tick_rate = self.tick_rate;
348        self.run_with_source(CrosstermEventSource::new(tick_rate));
349    }
350
351    /// Start the blocking event loop with a custom [`EventSource`].
352    ///
353    /// This variant exists primarily for testing with mock sources.
354    /// Returns when the source returns `None` or the channel is closed.
355    pub fn run_with_source(self, mut source: impl EventSource) {
356        while let Some(evt) = source.next_event() {
357            if self.tx.blocking_send(evt).is_err() {
358                break;
359            }
360        }
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use std::assert_matches;
368
369    #[test]
370    fn agent_event_debug() {
371        let e = AgentEvent::Chunk("hello".into());
372        let s = format!("{e:?}");
373        assert!(s.contains("Chunk"));
374    }
375
376    #[test]
377    fn app_event_variants() {
378        let tick = AppEvent::Tick;
379        assert_matches!(tick, AppEvent::Tick);
380
381        let resize = AppEvent::Resize(80, 24);
382        assert_matches!(resize, AppEvent::Resize(80, 24));
383    }
384
385    #[test]
386    fn event_reader_construction() {
387        let (tx, _rx) = mpsc::channel(16);
388        let reader = EventReader::new(tx, Duration::from_millis(100));
389        assert_eq!(reader.tick_rate, Duration::from_millis(100));
390    }
391
392    #[test]
393    fn confirm_request_debug() {
394        let (tx, _rx) = oneshot::channel();
395        let e = AgentEvent::ConfirmRequest {
396            prompt: "delete?".into(),
397            response_tx: tx,
398        };
399        let s = format!("{e:?}");
400        assert!(s.contains("ConfirmRequest"));
401        assert!(s.contains("delete?"));
402    }
403
404    #[test]
405    fn app_event_paste_variant() {
406        assert_matches!(AppEvent::Paste("x".into()), AppEvent::Paste(_));
407    }
408}