Skip to main content

zeph_tui/app/
events.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use tokio::sync::mpsc;
5
6use crate::event::{AgentEvent, AppEvent};
7
8use super::{App, ChatMessage, ConfirmState, ElicitationState, MessageRole, debug};
9
10impl App {
11    /// Dispatch a top-level [`AppEvent`] to the appropriate handler.
12    ///
13    /// Called once per event in the main [`crate::run_tui`] loop.
14    pub fn handle_event(&mut self, event: AppEvent) {
15        match event {
16            AppEvent::Key(key) => self.handle_key(key),
17            AppEvent::Tick => {
18                self.throbber_state.calc_next();
19            }
20            AppEvent::Resize(_, _) => {
21                self.sessions.current_mut().render_cache.clear();
22            }
23            AppEvent::MouseScroll(delta) => {
24                if self.confirm_state.is_none() {
25                    if delta > 0 {
26                        self.sessions.current_mut().scroll_offset =
27                            self.sessions.current().scroll_offset.saturating_add(1);
28                    } else {
29                        self.sessions.current_mut().scroll_offset =
30                            self.sessions.current().scroll_offset.saturating_sub(1);
31                    }
32                }
33            }
34            AppEvent::Agent(agent_event) => self.handle_agent_event(agent_event),
35            AppEvent::Paste(text) => self.handle_paste(&text),
36        }
37    }
38
39    /// Await the next [`AgentEvent`] from the agent channel.
40    ///
41    /// Returns `None` when all senders have been dropped (agent exited).
42    /// Called from the `select!` block in [`crate::run_tui`].
43    pub fn poll_agent_event(&mut self) -> impl Future<Output = Option<AgentEvent>> + use<'_> {
44        self.agent_event_rx.recv()
45    }
46
47    /// Non-blocking poll for a pending [`AgentEvent`].
48    ///
49    /// Used to drain the channel after a first event has been received,
50    /// coalescing multiple events into a single render frame.
51    ///
52    /// # Errors
53    ///
54    /// Returns `TryRecvError::Empty` if no events are pending, or
55    /// `TryRecvError::Disconnected` if the sender has been dropped.
56    pub fn try_recv_agent_event(&mut self) -> Result<AgentEvent, mpsc::error::TryRecvError> {
57        self.agent_event_rx.try_recv()
58    }
59
60    /// Handle an [`AgentEvent`] and update widget state accordingly.
61    ///
62    /// This is the main state-transition function for agent-driven updates:
63    /// appending streaming chunks, recording tool events, displaying confirm
64    /// dialogs, and wiring late-bound channels (cancel signal, metrics).
65    #[allow(clippy::too_many_lines)] // large match over all agent event variants
66    pub fn handle_agent_event(&mut self, event: AgentEvent) {
67        match event {
68            AgentEvent::Chunk(text) => {
69                self.sessions.current_mut().status_label = None;
70                if let Some(last) = self.sessions.current_mut().messages.last_mut()
71                    && last.role == MessageRole::Assistant
72                    && last.streaming
73                {
74                    last.content.push_str(&text);
75                } else {
76                    self.sessions
77                        .current_mut()
78                        .messages
79                        .push(ChatMessage::new(MessageRole::Assistant, text).streaming());
80                    self.trim_messages();
81                }
82                // No explicit cache invalidation needed: the cache key includes
83                // content_hash, so new chunk content causes a natural cache miss.
84                self.auto_scroll();
85            }
86            AgentEvent::FullMessage(text) => {
87                self.sessions.current_mut().status_label = None;
88                if !text.starts_with("[tool output") {
89                    self.sessions
90                        .current_mut()
91                        .messages
92                        .push(ChatMessage::new(MessageRole::Assistant, text));
93                    self.trim_messages();
94                }
95                self.auto_scroll();
96            }
97            AgentEvent::Flush => {
98                if let Some(last) = self.sessions.current_mut().messages.last_mut()
99                    && last.streaming
100                {
101                    last.streaming = false;
102                    let last_idx = self.sessions.current().messages.len().saturating_sub(1);
103                    self.sessions
104                        .current_mut()
105                        .render_cache
106                        .invalidate(last_idx);
107                }
108            }
109            AgentEvent::Typing => {
110                self.pending_count = self.pending_count.saturating_sub(1);
111                self.sessions.current_mut().status_label = Some("thinking...".to_owned());
112            }
113            AgentEvent::Status(text) => {
114                self.sessions.current_mut().status_label =
115                    if text.is_empty() { None } else { Some(text) };
116                self.auto_scroll();
117            }
118            AgentEvent::ToolStart { tool_name, command } => {
119                self.sessions.current_mut().status_label = None;
120                self.sessions.current_mut().messages.push(
121                    ChatMessage::new(MessageRole::Tool, format!("$ {command}\n"))
122                        .streaming()
123                        .with_tool(tool_name),
124                );
125                self.trim_messages();
126                self.auto_scroll();
127            }
128            AgentEvent::ToolOutputChunk { chunk, .. } => {
129                if let Some(pos) = self
130                    .sessions
131                    .current_mut()
132                    .messages
133                    .iter()
134                    .rposition(|m| m.role == MessageRole::Tool && m.streaming)
135                {
136                    self.sessions.current_mut().messages[pos]
137                        .content
138                        .push_str(&chunk);
139                    self.sessions.current_mut().render_cache.invalidate(pos);
140                }
141                self.auto_scroll();
142            }
143            AgentEvent::ToolOutput {
144                tool_name,
145                output,
146                diff,
147                filter_stats,
148                kept_lines,
149                ..
150            } => {
151                self.handle_tool_output_event(tool_name, output, diff, filter_stats, kept_lines);
152            }
153            AgentEvent::ConfirmRequest {
154                prompt,
155                response_tx,
156            } => {
157                self.confirm_state = Some(ConfirmState {
158                    prompt,
159                    response_tx: Some(response_tx),
160                });
161            }
162            AgentEvent::ElicitationRequest {
163                request,
164                response_tx,
165            } => {
166                let dialog = crate::widgets::elicitation::ElicitationDialogState::new(request);
167                self.elicitation_state = Some(ElicitationState {
168                    dialog,
169                    response_tx: Some(response_tx),
170                });
171            }
172            AgentEvent::QueueCount(count) => {
173                self.queued_count = count;
174                self.pending_count = count;
175            }
176            AgentEvent::DiffReady(diff) => self.handle_diff_ready(diff),
177            AgentEvent::CommandResult { output, .. } => {
178                self.command_palette = None;
179                self.sessions
180                    .current_mut()
181                    .messages
182                    .push(ChatMessage::new(MessageRole::System, output));
183                self.trim_messages();
184                self.auto_scroll();
185            }
186            AgentEvent::SetCancelSignal(signal) => {
187                self.set_cancel_signal(signal);
188            }
189            AgentEvent::SetMetricsRx(rx) => {
190                self.set_metrics_rx(rx);
191            }
192        }
193    }
194
195    fn handle_diff_ready(&mut self, diff: zeph_core::DiffData) {
196        if let Some(msg) = self
197            .sessions
198            .current_mut()
199            .messages
200            .iter_mut()
201            .rev()
202            .find(|m| m.role == MessageRole::Tool)
203        {
204            msg.diff_data = Some(diff);
205        }
206    }
207
208    fn handle_tool_output_event(
209        &mut self,
210        tool_name: zeph_common::ToolName,
211        output: String,
212        diff: Option<zeph_core::DiffData>,
213        filter_stats: Option<String>,
214        kept_lines: Option<Vec<usize>>,
215    ) {
216        debug!(
217            %tool_name,
218            has_diff = diff.is_some(),
219            has_filter_stats = filter_stats.is_some(),
220            output_len = output.len(),
221            "TUI ToolOutput event received"
222        );
223        if let Some(pos) = self
224            .sessions
225            .current_mut()
226            .messages
227            .iter()
228            .rposition(|m| m.role == MessageRole::Tool && m.streaming)
229        {
230            // Finalize existing streaming tool message (shell or native path with ToolStart).
231            // Replace content after the header line ("$ cmd\n") with the canonical body_display
232            // from ToolOutputEvent. Streaming chunks (Path B) may already occupy that space;
233            // appending would duplicate the output. Truncating to the header and re-writing
234            // body_display produces exactly one copy regardless of whether chunks arrived.
235            debug!("finalizing existing streaming Tool message");
236            let header_end = self.sessions.current_mut().messages[pos]
237                .content
238                .find('\n')
239                .map_or(0, |i| i + 1);
240            self.sessions.current_mut().messages[pos]
241                .content
242                .truncate(header_end);
243            self.sessions.current_mut().messages[pos]
244                .content
245                .push_str(&output);
246            self.sessions.current_mut().messages[pos].streaming = false;
247            self.sessions.current_mut().messages[pos].diff_data = diff;
248            self.sessions.current_mut().messages[pos].filter_stats = filter_stats;
249            self.sessions.current_mut().messages[pos].kept_lines = kept_lines;
250            self.sessions.current_mut().render_cache.invalidate(pos);
251        } else if diff.is_some() || filter_stats.is_some() || kept_lines.is_some() {
252            // No prior ToolStart: create the message now (legacy fallback).
253            debug!("creating new Tool message with diff (no prior ToolStart)");
254            let mut msg = ChatMessage::new(MessageRole::Tool, output).with_tool(tool_name);
255            msg.diff_data = diff;
256            msg.filter_stats = filter_stats;
257            msg.kept_lines = kept_lines;
258            self.sessions.current_mut().messages.push(msg);
259            self.trim_messages();
260        } else if let Some(msg) = self
261            .sessions
262            .current_mut()
263            .messages
264            .iter_mut()
265            .rev()
266            .find(|m| m.role == MessageRole::Tool)
267        {
268            msg.filter_stats = filter_stats;
269        }
270        self.auto_scroll();
271    }
272
273    #[must_use]
274    pub fn confirm_state(&self) -> Option<&ConfirmState> {
275        self.confirm_state.as_ref()
276    }
277}