zeph-tui 0.22.4

Ratatui-based TUI dashboard with real-time metrics for Zeph
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
// SPDX-License-Identifier: MIT OR Apache-2.0

use std::sync::Arc;
use std::time::Duration;

use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEvent, MouseEventKind};
use tokio::sync::{Notify, mpsc, oneshot, watch};

use zeph_core::metrics::MetricsSnapshot;

/// Source of raw terminal events consumed by [`EventReader`].
///
/// Implement this trait to provide a custom event source (e.g. a mock for
/// testing or a replay driver).
///
/// # Examples
///
/// ```rust
/// use zeph_tui::event::{AppEvent, EventSource};
/// use crossterm::event::KeyEvent;
///
/// struct OneTickSource;
///
/// impl EventSource for OneTickSource {
///     fn next_event(&mut self) -> Option<AppEvent> {
///         None // signal EOF
///     }
/// }
/// ```
pub trait EventSource: Send + 'static {
    /// Return the next event, or `None` to signal that the source is exhausted
    /// and the event loop should terminate.
    fn next_event(&mut self) -> Option<AppEvent>;
}

/// [`EventSource`] backed by crossterm's blocking event poll.
///
/// Polls for terminal events up to `tick_rate` before returning a
/// [`AppEvent::Tick`] if no event arrived. This drives the TUI's animation
/// and idle redraw cadence.
///
/// # Examples
///
/// ```rust
/// use std::time::Duration;
/// use zeph_tui::CrosstermEventSource;
///
/// let source = CrosstermEventSource::new(Duration::from_millis(250));
/// ```
pub struct CrosstermEventSource {
    tick_rate: Duration,
}

impl CrosstermEventSource {
    /// Create a new source with the given poll interval.
    ///
    /// # Examples
    ///
    /// ```rust
    /// use std::time::Duration;
    /// use zeph_tui::CrosstermEventSource;
    ///
    /// let src = CrosstermEventSource::new(Duration::from_millis(100));
    /// ```
    #[must_use]
    pub fn new(tick_rate: Duration) -> Self {
        Self { tick_rate }
    }
}

impl EventSource for CrosstermEventSource {
    fn next_event(&mut self) -> Option<AppEvent> {
        if event::poll(self.tick_rate).unwrap_or(false) {
            match event::read() {
                Ok(CrosstermEvent::Key(key)) => Some(AppEvent::Key(key)),
                Ok(CrosstermEvent::Resize(w, h)) => Some(AppEvent::Resize(w, h)),
                Ok(CrosstermEvent::Paste(text)) => Some(AppEvent::Paste(text)),
                Ok(CrosstermEvent::Mouse(m)) => {
                    // C6: filter high-frequency motion events to Tick to avoid
                    // setting dirty=Full on every cursor move, which would
                    // stall the render loop with unnecessary full redraws.
                    match m.kind {
                        MouseEventKind::Moved | MouseEventKind::Drag(_) => Some(AppEvent::Tick),
                        _ => Some(AppEvent::Mouse(m)),
                    }
                }
                _ => Some(AppEvent::Tick),
            }
        } else {
            Some(AppEvent::Tick)
        }
    }
}

/// Top-level event consumed by the [`crate::App`] event handler.
///
/// Events arrive from two sources:
/// - Terminal input via [`EventReader`] / [`CrosstermEventSource`].
/// - Agent output forwarded through [`AgentEvent`] by [`crate::TuiChannel`].
///
/// # Examples
///
/// ```rust
/// use zeph_tui::event::AppEvent;
///
/// let ev = AppEvent::Tick;
/// assert!(matches!(ev, AppEvent::Tick));
/// ```
#[non_exhaustive]
#[derive(Debug)]
pub enum AppEvent {
    /// A keyboard event from crossterm.
    Key(KeyEvent),
    /// Periodic tick used to drive animations and idle redraws.
    Tick,
    /// The terminal was resized to the given `(columns, rows)`.
    Resize(u16, u16),
    /// An event forwarded from the agent event channel.
    Agent(AgentEvent),
    /// Text pasted via bracketed paste mode.
    ///
    /// The string may contain `\n` characters (multiline paste). The app
    /// inserts it verbatim into the input buffer; Enter is still required
    /// to submit (matching vim/neovim behaviour).
    Paste(String),
    /// A mouse event from crossterm (only produced when mouse capture is enabled via
    /// `/mouse on`). High-frequency `Moved` and `Drag` events are folded into
    /// [`AppEvent::Tick`] by [`CrosstermEventSource`] before reaching this variant (C6).
    Mouse(MouseEvent),
}

/// Events produced by the agent and forwarded to the TUI via [`crate::TuiChannel`].
///
/// Each variant corresponds to a distinct phase or signal in the agent lifecycle
/// (streaming output, tool execution, user confirmation, etc.).
///
/// # Examples
///
/// ```rust
/// use zeph_tui::event::AgentEvent;
///
/// let ev = AgentEvent::Chunk("partial response".to_string());
/// assert!(matches!(ev, AgentEvent::Chunk(_)));
/// ```
#[non_exhaustive]
#[derive(Debug)]
pub enum AgentEvent {
    /// A streaming text chunk from the LLM — appended to the current message.
    Chunk(String),
    /// A complete (non-streaming) assistant message.
    FullMessage(String),
    /// Signals that streaming is complete; the chat widget stops the cursor.
    Flush,
    /// The agent is waiting for an LLM response (drives the throbber).
    Typing,
    /// A short status string to display in the activity bar (e.g. `"Searching memory…"`).
    Status(String),
    /// A tool call has started; the TUI should display a spinner with the tool name.
    ToolStart {
        /// Canonical tool name (e.g. `"bash"`, `"read_file"`).
        tool_name: zeph_common::ToolName,
        /// The primary command or argument string shown in the status bar.
        command: String,
        /// Opaque tool-call identifier for correlating subsequent events.
        tool_call_id: String,
        /// True when this tool call originates from an MCP server rather than a native tool.
        is_mcp: bool,
    },
    /// An incremental output chunk from a long-running tool (e.g. streaming shell output).
    ToolOutputChunk {
        /// Tool that produced the chunk.
        tool_name: zeph_common::ToolName,
        /// Command argument associated with the tool call.
        command: String,
        /// The chunk text to append.
        chunk: String,
        /// Opaque tool-call identifier for id-based message lookup.
        tool_call_id: String,
    },
    /// Final tool output, replacing any in-progress chunks for this call.
    ToolOutput {
        /// Tool that produced the output.
        tool_name: zeph_common::ToolName,
        /// Command argument associated with the tool call.
        command: String,
        /// Full rendered output body.
        output: String,
        /// `true` if the tool succeeded, `false` on error.
        success: bool,
        /// Optional diff to display inline in the chat.
        diff: Option<zeph_core::DiffData>,
        /// Human-readable filter summary, if output was filtered.
        filter_stats: Option<String>,
        /// Indices of lines retained by the filter.
        kept_lines: Option<Vec<usize>>,
        /// Opaque tool-call identifier for id-based message lookup.
        tool_call_id: String,
    },
    /// The agent requests a boolean confirmation from the user.
    ConfirmRequest {
        /// Prompt text shown in the confirmation dialog.
        prompt: String,
        /// One-shot channel to send the user's `true`/`false` response.
        response_tx: oneshot::Sender<bool>,
    },
    /// The agent requests structured input via an elicitation dialog.
    ElicitationRequest {
        /// The elicitation schema and prompt.
        request: zeph_core::channel::ElicitationRequest,
        /// One-shot channel to send the user's response.
        response_tx: oneshot::Sender<zeph_core::channel::ElicitationResponse>,
    },
    /// Updated count of messages queued for the agent (shown in the input bar).
    QueueCount(usize),
    /// A diff is ready for immediate display in the diff panel.
    DiffReady {
        /// The diff payload to attach to the corresponding tool message.
        diff: zeph_core::DiffData,
        /// Identifies which tool call produced this diff.
        tool_call_id: String,
    },
    /// Result from a slash-command dispatched to the agent.
    CommandResult {
        /// The slash-command identifier that produced this result.
        command_id: String,
        /// Formatted command output to display.
        output: String,
    },
    /// Wire a cancel signal into the TUI App after early startup (Phase 2).
    SetCancelSignal(Arc<Notify>),
    /// Wire a metrics receiver into the TUI App after early startup (Phase 2).
    SetMetricsRx(watch::Receiver<MetricsSnapshot>),
    /// Wire a [`zeph_common::task_supervisor::TaskSupervisor`] into the TUI App after
    /// early startup (Phase 2), so the task registry panel reflects live task state
    /// instead of reporting "supervisor not available".
    SetTaskSupervisor(zeph_common::task_supervisor::TaskSupervisor),
    /// A foreground subagent has been spawned; the TUI should switch view to its transcript.
    ForegroundSubagentStarted {
        /// Stable sub-agent identifier (`task_id` from `SubAgentManager`).
        id: String,
        /// Human-readable agent definition name.
        name: String,
    },
    /// A foreground subagent has reached a terminal state; the TUI should return to Main view.
    ForegroundSubagentCompleted {
        /// Stable sub-agent identifier.
        id: String,
        /// Human-readable agent definition name.
        name: String,
        /// `true` if Completed state, `false` if Failed/Canceled.
        success: bool,
    },
    /// A background subagent (`/agent bg`) has reached a terminal state.
    ///
    /// Fires for every background subagent, not just one the parent turn is blocking on.
    /// The TUI only acts on it when `id` matches the subagent currently being viewed via the
    /// sidebar's manual transcript view — resetting to Main and rendering a terminal marker,
    /// since the sidebar list and the transcript reload trigger both key off
    /// `MetricsSnapshot::sub_agents`, which no longer contains a completed agent by the time
    /// this event is observed (#6570). Subagents not being viewed need no action here: their
    /// completion notice is already pushed to Main chat via [`AgentEvent::FullMessage`] from
    /// `Channel::send` in `notify_completed_subagents`.
    BackgroundSubagentCompleted {
        /// Stable sub-agent identifier.
        id: String,
        /// Human-readable agent definition name.
        name: String,
        /// `true` if Completed state, `false` if Failed/Canceled.
        success: bool,
    },
    /// Current context token count estimate, updated after each context assembly.
    ///
    /// The value is an approximation based on character-level heuristics and may
    /// diverge slightly from the actual token count sent to the LLM. Stale between
    /// turns (the previous turn's estimate remains displayed until the next assembly).
    ContextEstimate(usize),
    /// Updated fleet snapshot from the background DB poll task (#3884).
    FleetSnapshot(crate::widgets::fleet::FleetSnapshot),
    /// Updated durable execution snapshot from the background poll task (spec-064, #4949).
    DurableSnapshot(crate::widgets::durable::DurableSnapshot),
    /// A non-empty prior conversation was resumed at startup (spec-068 §13.5).
    ///
    /// Renders as a **persistent** banner in the header/status area — unlike
    /// [`AgentEvent::Status`], it must remain visible once the first prompt scrolls the
    /// transient status line out of view. Never sent for a fresh (system-prompt-only)
    /// conversation (§13.4, AC-16).
    ResumeBanner(String),
    /// Bounded `/history` transcript slice to backfill into the display buffer (spec-068
    /// §13.6-§13.7).
    ///
    /// Pushed as distinct chat messages via `App::backfill_history_display_only`, split from
    /// `input_history`/up-arrow recall (INV-SP-6, AC-20) — never routed through
    /// `App::load_history`, which also feeds `input_history`.
    HistoryBackfill(Vec<zeph_commands::TranscriptEntry>),
    /// Full skill catalog (name + description), emitted once at agent startup and
    /// re-emitted on skill hot-reload (spec 084 §6, issue #6648). Stored into
    /// `App::skill_catalog` and used to refresh an open mention picker's Skills tab.
    SkillCatalog(Arc<[zeph_core::channel::SkillCatalogItem]>),
}

/// Blocking event pump that forwards terminal events to the async [`AppEvent`] channel.
///
/// `EventReader` must run on a **dedicated `std::thread`** — it calls
/// `blocking_send` and crossterm's blocking poll, which would stall a tokio
/// worker thread.
///
/// # Examples
///
/// ```rust,no_run
/// use std::time::Duration;
/// use tokio::sync::mpsc;
/// use zeph_tui::EventReader;
///
/// let (tx, rx) = mpsc::channel(64);
/// let reader = EventReader::new(tx, Duration::from_millis(250));
/// std::thread::spawn(|| reader.run());
/// ```
pub struct EventReader {
    tx: mpsc::Sender<AppEvent>,
    tick_rate: Duration,
}

impl EventReader {
    /// Create a new reader that sends events to `tx` at up to `tick_rate` cadence.
    ///
    /// # Examples
    ///
    /// ```rust
    /// use std::time::Duration;
    /// use tokio::sync::mpsc;
    /// use zeph_tui::EventReader;
    ///
    /// let (tx, _rx) = mpsc::channel(64);
    /// let reader = EventReader::new(tx, Duration::from_millis(250));
    /// ```
    #[must_use]
    pub fn new(tx: mpsc::Sender<AppEvent>, tick_rate: Duration) -> Self {
        Self { tx, tick_rate }
    }

    /// Start the blocking event loop using the default [`CrosstermEventSource`].
    ///
    /// **Must be called from a dedicated `std::thread`**, not a tokio worker.
    /// Returns when the [`AppEvent`] channel receiver is dropped.
    pub fn run(self) {
        let tick_rate = self.tick_rate;
        self.run_with_source(CrosstermEventSource::new(tick_rate));
    }

    /// Start the blocking event loop with a custom [`EventSource`].
    ///
    /// This variant exists primarily for testing with mock sources.
    /// Returns when the source returns `None` or the channel is closed.
    pub fn run_with_source(self, mut source: impl EventSource) {
        while let Some(evt) = source.next_event() {
            if self.tx.blocking_send(evt).is_err() {
                break;
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::assert_matches;

    #[test]
    fn agent_event_debug() {
        let e = AgentEvent::Chunk("hello".into());
        let s = format!("{e:?}");
        assert!(s.contains("Chunk"));
    }

    #[test]
    fn app_event_variants() {
        let tick = AppEvent::Tick;
        assert_matches!(tick, AppEvent::Tick);

        let resize = AppEvent::Resize(80, 24);
        assert_matches!(resize, AppEvent::Resize(80, 24));
    }

    #[test]
    fn event_reader_construction() {
        let (tx, _rx) = mpsc::channel(16);
        let reader = EventReader::new(tx, Duration::from_millis(100));
        assert_eq!(reader.tick_rate, Duration::from_millis(100));
    }

    #[test]
    fn confirm_request_debug() {
        let (tx, _rx) = oneshot::channel();
        let e = AgentEvent::ConfirmRequest {
            prompt: "delete?".into(),
            response_tx: tx,
        };
        let s = format!("{e:?}");
        assert!(s.contains("ConfirmRequest"));
        assert!(s.contains("delete?"));
    }

    #[test]
    fn app_event_paste_variant() {
        assert_matches!(AppEvent::Paste("x".into()), AppEvent::Paste(_));
    }
}