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 std::time::Instant;
5
6use tokio::sync::mpsc;
7
8use crate::event::{AgentEvent, AppEvent};
9
10use super::{App, ChatMessage, ConfirmState, ElicitationState, MessageRole, debug};
11
12impl App {
13    /// Dispatch a top-level [`AppEvent`] to the appropriate handler.
14    ///
15    /// Called once per event in the main [`crate::run_tui`] loop.
16    pub fn handle_event(&mut self, event: AppEvent) {
17        match event {
18            AppEvent::Key(key) => self.handle_key(key),
19            AppEvent::Tick => {
20                self.throbber_state.calc_next();
21                self.wave_tick = self.wave_tick.saturating_add(1);
22                self.tick_delights();
23            }
24            AppEvent::Resize(_, _) => {
25                self.sessions.current_mut().render_cache.clear();
26            }
27            AppEvent::Agent(agent_event) => self.handle_agent_event(agent_event),
28            // Routed through `reduce` (S1, spec 084) rather than calling `handle_paste`
29            // directly: this is behaviour-identical (the `InsertText` reducer arm just
30            // calls `handle_paste`) but gives paste the mention-picker resync for free —
31            // without it, pasting before the `@` shifts the buffer while `at_char_index`
32            // stays fixed, which can invert `MentionPickerAccept`'s replacement range.
33            AppEvent::Paste(text) => {
34                let effects =
35                    crate::app::reducer::reduce(self, crate::app::action::Action::InsertText(text));
36                crate::app::reducer::run_effects(self, effects);
37            }
38            AppEvent::Mouse(m) => self.handle_mouse(m),
39        }
40    }
41
42    /// Await the next [`AgentEvent`] from the agent channel.
43    ///
44    /// Returns `None` when all senders have been dropped (agent exited).
45    /// Called from the `select!` block in [`crate::run_tui`].
46    pub fn poll_agent_event(&mut self) -> impl Future<Output = Option<AgentEvent>> + use<'_> {
47        self.agent_event_rx.recv()
48    }
49
50    /// Non-blocking poll for a pending [`AgentEvent`].
51    ///
52    /// Used to drain the channel after a first event has been received,
53    /// coalescing multiple events into a single render frame.
54    ///
55    /// # Errors
56    ///
57    /// Returns `TryRecvError::Empty` if no events are pending, or
58    /// `TryRecvError::Disconnected` if the sender has been dropped.
59    pub fn try_recv_agent_event(&mut self) -> Result<AgentEvent, mpsc::error::TryRecvError> {
60        self.agent_event_rx.try_recv()
61    }
62
63    /// Handle an [`AgentEvent`] and update widget state accordingly.
64    ///
65    /// This is the main state-transition function for agent-driven updates:
66    /// appending streaming chunks, recording tool events, displaying confirm
67    /// dialogs, and wiring late-bound channels (cancel signal, metrics).
68    #[allow(clippy::too_many_lines)] // large match over all agent event variants
69    pub fn handle_agent_event(&mut self, event: AgentEvent) {
70        match event {
71            AgentEvent::Chunk(text) => {
72                self.sessions.current_mut().status_label = None;
73                // New token chunk — refresh stall clock.
74                self.last_progress_at = Instant::now();
75                if let Some(last) = self.sessions.current_mut().messages.last_mut()
76                    && last.role == MessageRole::Assistant
77                    && last.streaming
78                {
79                    last.content.push_str(&text);
80                } else {
81                    self.sessions
82                        .current_mut()
83                        .messages
84                        .push(ChatMessage::new(MessageRole::Assistant, text).streaming());
85                    self.trim_messages();
86                }
87                // Micro-delight: update streaming rate estimate with current completion tokens.
88                let completion_tokens = self.metrics.completion_tokens;
89                self.stream_rate.on_token_chunk(completion_tokens);
90                // No explicit cache invalidation needed: the cache key includes
91                // content_hash, so new chunk content causes a natural cache miss.
92                self.auto_scroll();
93            }
94            AgentEvent::FullMessage(text) => {
95                self.sessions.current_mut().status_label = None;
96                if !text.starts_with("[tool output") {
97                    self.sessions
98                        .current_mut()
99                        .messages
100                        .push(ChatMessage::new(MessageRole::Assistant, text));
101                    self.trim_messages();
102                }
103                self.auto_scroll();
104            }
105            AgentEvent::Flush => {
106                if let Some(last) = self.sessions.current_mut().messages.last_mut()
107                    && last.streaming
108                {
109                    last.streaming = false;
110                    let last_idx = self.sessions.current().messages.len().saturating_sub(1);
111                    self.sessions
112                        .current_mut()
113                        .render_cache
114                        .invalidate(last_idx);
115                }
116            }
117            AgentEvent::Typing => {
118                self.pending_count = self.pending_count.saturating_sub(1);
119                self.sessions.current_mut().status_label = Some("thinking...".to_owned());
120                // Turn begins — initialize the stall clock so the first frame is Swell, not Stalled.
121                self.last_progress_at = Instant::now();
122                // Micro-delight: reset streaming rate tracker for this new turn.
123                self.stream_rate.on_turn_start();
124            }
125            AgentEvent::Status(text) => {
126                self.sessions.current_mut().status_label =
127                    if text.is_empty() { None } else { Some(text) };
128                // Non-empty status update counts as progress (supervisor activity, tool dispatch…).
129                if self.sessions.current().status_label.is_some() {
130                    self.last_progress_at = Instant::now();
131                }
132                self.auto_scroll();
133            }
134            AgentEvent::ToolStart {
135                tool_name,
136                command,
137                tool_call_id,
138                is_mcp,
139            } => {
140                self.sessions.current_mut().status_label = None;
141                self.sessions.current_mut().messages.push(
142                    ChatMessage::new(MessageRole::Tool, format!("$ {command}\n"))
143                        .streaming()
144                        .with_tool(tool_name)
145                        .with_tool_call_id(tool_call_id)
146                        .with_is_mcp(is_mcp),
147                );
148                self.trim_messages();
149                self.auto_scroll();
150            }
151            AgentEvent::ToolOutputChunk {
152                chunk,
153                tool_call_id,
154                ..
155            } => {
156                self.last_progress_at = Instant::now();
157                let pos = if tool_call_id.is_empty() {
158                    // Shell tool chunks arrive without a tool_call_id; fall back to the last
159                    // streaming Tool message (there is at most one active at a time).
160                    self.sessions
161                        .current()
162                        .messages
163                        .iter()
164                        .rposition(|m| m.role == MessageRole::Tool && m.streaming)
165                } else {
166                    let found =
167                        self.sessions.current().messages.iter().rposition(|m| {
168                            m.tool_call_id.as_deref() == Some(tool_call_id.as_str())
169                        });
170                    if found.is_none() {
171                        tracing::warn!(
172                            %tool_call_id,
173                            "ToolOutputChunk: no message with matching tool_call_id — dropping chunk"
174                        );
175                    }
176                    found
177                };
178                if let Some(pos) = pos {
179                    self.sessions.current_mut().messages[pos]
180                        .content
181                        .push_str(&chunk);
182                    self.sessions.current_mut().render_cache.invalidate(pos);
183                }
184                self.auto_scroll();
185            }
186            AgentEvent::ToolOutput {
187                tool_name,
188                output,
189                diff,
190                filter_stats,
191                kept_lines,
192                success,
193                tool_call_id,
194                ..
195            } => {
196                self.handle_tool_output_event(
197                    tool_name,
198                    output,
199                    diff,
200                    filter_stats,
201                    kept_lines,
202                    success,
203                    tool_call_id,
204                );
205            }
206            AgentEvent::ConfirmRequest {
207                prompt,
208                response_tx,
209            } => {
210                self.confirm_state = Some(ConfirmState {
211                    prompt,
212                    response_tx: Some(response_tx),
213                });
214            }
215            AgentEvent::ElicitationRequest {
216                request,
217                response_tx,
218            } => {
219                let dialog = crate::widgets::elicitation::ElicitationDialogState::new(request);
220                self.elicitation_state = Some(ElicitationState {
221                    dialog,
222                    response_tx: Some(response_tx),
223                });
224            }
225            AgentEvent::QueueCount(count) => {
226                self.queued_count = count;
227                self.pending_count = count;
228            }
229            AgentEvent::DiffReady { diff, tool_call_id } => {
230                self.handle_diff_ready(diff, &tool_call_id);
231            }
232            AgentEvent::CommandResult { output, .. } => {
233                self.command_palette = None;
234                self.sessions
235                    .current_mut()
236                    .messages
237                    .push(ChatMessage::new(MessageRole::System, output));
238                self.trim_messages();
239                self.auto_scroll();
240            }
241            AgentEvent::SetCancelSignal(signal) => {
242                self.set_cancel_signal(signal);
243            }
244            AgentEvent::SetMetricsRx(rx) => {
245                self.set_metrics_rx(rx);
246            }
247            AgentEvent::SetTaskSupervisor(supervisor) => {
248                self.set_task_supervisor(supervisor);
249            }
250            AgentEvent::ForegroundSubagentStarted { id, name } => {
251                self.sessions.current_mut().status_label =
252                    Some(format!("Sub-agent '{name}' running..."));
253                // Status change counts as progress so the wave animates (never reads Stalled).
254                self.last_progress_at = Instant::now();
255                self.set_view_target(super::AgentViewTarget::SubAgent { id, name });
256            }
257            AgentEvent::ForegroundSubagentCompleted { id, name, success } => {
258                // Only switch back to Main if we are still viewing this subagent.
259                // If the user manually navigated away, respect that choice.
260                if self.sessions.current().view_target.subagent_id() == Some(id.as_str()) {
261                    self.set_view_target(super::AgentViewTarget::Main);
262                }
263                let label = if success {
264                    format!("Sub-agent '{name}' completed")
265                } else {
266                    format!("Sub-agent '{name}' failed")
267                };
268                self.sessions.current_mut().status_label = Some(label.clone());
269                // Status change counts as progress so the wave animates (never reads Stalled).
270                self.last_progress_at = Instant::now();
271                self.sessions
272                    .current_mut()
273                    .messages
274                    .push(ChatMessage::new(MessageRole::System, label));
275                self.trim_messages();
276                self.auto_scroll();
277            }
278            AgentEvent::BackgroundSubagentCompleted { id, name, success } => {
279                // Only act if the user is currently viewing this subagent's transcript —
280                // background completions not being viewed are already surfaced via the
281                // Channel::send notice pushed to Main chat (notify_completed_subagents), so
282                // acting unconditionally here would double-notify every background subagent.
283                if self.sessions.current().view_target.subagent_id() == Some(id.as_str()) {
284                    let label = if success {
285                        format!("Sub-agent '{name}' completed")
286                    } else {
287                        format!("Sub-agent '{name}' failed")
288                    };
289                    self.set_view_target(super::AgentViewTarget::Main);
290                    self.sessions.current_mut().status_label = Some(label.clone());
291                    // Status change counts as progress so the wave animates (never reads Stalled).
292                    self.last_progress_at = Instant::now();
293                    self.sessions
294                        .current_mut()
295                        .messages
296                        .push(ChatMessage::new(MessageRole::System, label));
297                    self.trim_messages();
298                    self.auto_scroll();
299                }
300            }
301            AgentEvent::ContextEstimate(tokens) => {
302                self.context_token_estimate = tokens;
303            }
304            AgentEvent::FleetSnapshot(snapshot) => {
305                self.fleet_snapshot = snapshot;
306            }
307            AgentEvent::DurableSnapshot(snapshot) => {
308                self.durable_snapshot = snapshot;
309            }
310            AgentEvent::ResumeBanner(text) => {
311                self.resume_banner = Some(text);
312            }
313            AgentEvent::HistoryBackfill(entries) => {
314                self.backfill_history_display_only(&entries);
315            }
316            AgentEvent::SkillCatalog(items) => {
317                self.skill_catalog = Some(items);
318                if self.mention_picker.is_some() {
319                    let query = crate::app::reducer::mention_picker_query(self);
320                    let skills = self.skill_catalog.clone();
321                    if let Some(picker) = self.mention_picker.as_mut() {
322                        picker.catalog.skills = skills;
323                        picker.refilter(&query);
324                    }
325                }
326            }
327        }
328    }
329
330    fn handle_diff_ready(&mut self, diff: zeph_core::DiffData, tool_call_id: &str) {
331        if let Some(msg) = self
332            .sessions
333            .current_mut()
334            .messages
335            .iter_mut()
336            .rev()
337            .find(|m| {
338                m.role == MessageRole::Tool && m.tool_call_id.as_deref() == Some(tool_call_id)
339            })
340        {
341            msg.diff_data = Some(diff);
342        }
343    }
344
345    #[allow(clippy::too_many_arguments)]
346    fn handle_tool_output_event(
347        &mut self,
348        tool_name: zeph_common::ToolName,
349        output: String,
350        diff: Option<zeph_core::DiffData>,
351        filter_stats: Option<String>,
352        kept_lines: Option<Vec<usize>>,
353        success: bool,
354        tool_call_id: String,
355    ) {
356        debug!(
357            %tool_name,
358            has_diff = diff.is_some(),
359            has_filter_stats = filter_stats.is_some(),
360            output_len = output.len(),
361            "TUI ToolOutput event received"
362        );
363        // Try id-based lookup first; fall back to streaming-flag lookup for
364        // cases where ToolStart was not emitted (legacy path, empty tool_call_id).
365        let pos = if tool_call_id.is_empty() {
366            self.sessions
367                .current()
368                .messages
369                .iter()
370                .rposition(|m| m.role == MessageRole::Tool && m.streaming)
371        } else {
372            let found = self
373                .sessions
374                .current()
375                .messages
376                .iter()
377                .rposition(|m| {
378                    m.role == MessageRole::Tool
379                        && m.streaming
380                        && m.tool_call_id.as_deref() == Some(tool_call_id.as_str())
381                })
382                .or_else(|| {
383                    self.sessions
384                        .current()
385                        .messages
386                        .iter()
387                        .rposition(|m| m.role == MessageRole::Tool && m.streaming)
388                });
389            if found.is_none() {
390                tracing::warn!(
391                    tool_call_id = %tool_call_id,
392                    "ToolOutput: no streaming Tool message found — skipping finalization"
393                );
394            }
395            found
396        };
397
398        if let Some(pos) = pos {
399            // Finalize existing streaming tool message (shell or native path with ToolStart).
400            // Replace content after the header line ("$ cmd\n") with the canonical body_display
401            // from ToolOutputEvent. Streaming chunks (Path B) may already occupy that space;
402            // appending would duplicate the output. Truncating to the header and re-writing
403            // body_display produces exactly one copy regardless of whether chunks arrived.
404            debug!("finalizing existing streaming Tool message");
405            let header_end = self.sessions.current_mut().messages[pos]
406                .content
407                .find('\n')
408                .map_or(0, |i| i + 1);
409            self.sessions.current_mut().messages[pos]
410                .content
411                .truncate(header_end);
412            self.sessions.current_mut().messages[pos]
413                .content
414                .push_str(&output);
415            self.sessions.current_mut().messages[pos].streaming = false;
416            self.sessions.current_mut().messages[pos].diff_data = diff;
417            self.sessions.current_mut().messages[pos].filter_stats = filter_stats;
418            self.sessions.current_mut().messages[pos].kept_lines = kept_lines;
419            self.sessions.current_mut().messages[pos].success = Some(success);
420            self.sessions.current_mut().render_cache.invalidate(pos);
421        } else if diff.is_some() || filter_stats.is_some() || kept_lines.is_some() {
422            // No prior ToolStart: create the message now (legacy fallback).
423            debug!("creating new Tool message with diff (no prior ToolStart)");
424            let mut msg = ChatMessage::new(MessageRole::Tool, output)
425                .with_tool(tool_name)
426                .with_tool_call_id(tool_call_id);
427            msg.diff_data = diff;
428            msg.filter_stats = filter_stats;
429            msg.kept_lines = kept_lines;
430            msg.success = Some(success);
431            self.sessions.current_mut().messages.push(msg);
432            self.trim_messages();
433        } else if let Some(msg) = self
434            .sessions
435            .current_mut()
436            .messages
437            .iter_mut()
438            .rev()
439            .find(|m| m.role == MessageRole::Tool)
440        {
441            msg.filter_stats = filter_stats;
442        }
443        self.auto_scroll();
444        self.maybe_flash_completed_group();
445    }
446
447    /// If the most recently completed tool message belongs to a fully-resolved group,
448    /// trigger a completion flash for that group (#5104).
449    ///
450    /// Groups are defined as a contiguous run of [`MessageRole::Tool`] messages in the
451    /// transcript. We scan backward from the last Tool message to find the group's
452    /// `start_idx`, then verify that every message in the run is no longer streaming.
453    fn maybe_flash_completed_group(&mut self) {
454        if self.motion == zeph_config::Motion::Off || !self.delights.completion_flash {
455            return;
456        }
457
458        let messages = &self.sessions.current().messages;
459
460        // Find the last Tool message index.
461        let Some(last_tool_pos) = messages.iter().rposition(|m| m.role == MessageRole::Tool) else {
462            return;
463        };
464
465        // Walk backward to find the start of the contiguous Tool run.
466        let mut start_idx = last_tool_pos;
467        while start_idx > 0 && messages[start_idx - 1].role == MessageRole::Tool {
468            start_idx -= 1;
469        }
470
471        // Check that every message in this run is finalized (not streaming).
472        let all_done = messages[start_idx..=last_tool_pos]
473            .iter()
474            .all(|m| !m.streaming && m.success.is_some());
475        if !all_done {
476            return;
477        }
478
479        // Avoid re-flashing a group that already flashed this tick cycle.
480        if self.sessions.current().flashed_groups.contains(&start_idx) {
481            return;
482        }
483
484        let group_size = last_tool_pos - start_idx + 1;
485        let now = self.anim_tick();
486        self.sessions.current_mut().flashed_groups.insert(start_idx);
487        self.sessions.current_mut().flash.insert(start_idx, now);
488
489        // Show a transient success toast when the toasts delight is also enabled.
490        if self.delights.toasts {
491            let text = if group_size == 1 {
492                "Tool done".to_owned()
493            } else {
494                format!("{group_size} tools done")
495            };
496            self.push_toast(text, crate::delights::ToastKind::Success);
497        }
498    }
499
500    #[must_use]
501    pub fn confirm_state(&self) -> Option<&ConfirmState> {
502        self.confirm_state.as_ref()
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use tokio::sync::mpsc;
509
510    use crate::app::{AgentViewTarget, App};
511    use crate::event::AgentEvent;
512    use crate::types::{ChatMessage, MessageRole};
513    use zeph_core::DiffData;
514
515    fn make_app() -> App {
516        let (user_tx, agent_rx) = {
517            let (utx, _urx) = mpsc::channel(8);
518            let (_atx, arx) = mpsc::channel(8);
519            (utx, arx)
520        };
521        let mut app = App::new(user_tx, agent_rx);
522        app.sessions.current_mut().messages.clear();
523        app
524    }
525
526    /// Push a streaming Tool message with a specific `tool_call_id` directly onto the session.
527    fn push_tool_msg(app: &mut App, id: &str) {
528        let msg = ChatMessage::new(MessageRole::Tool, format!("$ cmd_{id}\n"))
529            .streaming()
530            .with_tool_call_id(id.to_owned());
531        app.sessions.current_mut().messages.push(msg);
532    }
533
534    fn tool_msg(id: &str) -> ChatMessage {
535        ChatMessage::new(MessageRole::Tool, "$ cmd\n".to_owned())
536            .with_tool("bash".into())
537            .with_tool_call_id(id.to_owned())
538    }
539
540    fn diff() -> DiffData {
541        DiffData {
542            file_path: "a.rs".into(),
543            old_content: "old".into(),
544            new_content: "new".into(),
545        }
546    }
547
548    #[test]
549    fn tool_output_chunk_routes_by_id_out_of_order() {
550        let mut app = make_app();
551        push_tool_msg(&mut app, "a");
552        push_tool_msg(&mut app, "b");
553        push_tool_msg(&mut app, "c");
554
555        // Deliver chunks out of order: c, a, b, a, c
556        for (id, chunk) in [
557            ("c", "c1"),
558            ("a", "a1"),
559            ("b", "b1"),
560            ("a", "a2"),
561            ("c", "c2"),
562        ] {
563            app.handle_agent_event(AgentEvent::ToolOutputChunk {
564                tool_name: "bash".into(),
565                command: String::new(),
566                chunk: chunk.to_owned(),
567                tool_call_id: id.to_owned(),
568            });
569        }
570
571        let msgs = app.messages();
572        assert_eq!(msgs.len(), 3);
573        // Message order: a=0, b=1, c=2
574        assert_eq!(msgs[0].content, "$ cmd_a\na1a2");
575        assert_eq!(msgs[1].content, "$ cmd_b\nb1");
576        assert_eq!(msgs[2].content, "$ cmd_c\nc1c2");
577    }
578
579    #[test]
580    fn tool_output_chunk_with_unknown_id_is_dropped() {
581        let mut app = make_app();
582        push_tool_msg(&mut app, "known");
583
584        // Chunk for an id that has no matching message — must be silently dropped.
585        app.handle_agent_event(AgentEvent::ToolOutputChunk {
586            tool_name: "bash".into(),
587            command: String::new(),
588            chunk: "should-not-appear".to_owned(),
589            tool_call_id: "unknown-xyz".to_owned(),
590        });
591
592        // The known message must be unchanged.
593        assert_eq!(app.messages().len(), 1);
594        assert_eq!(app.messages()[0].content, "$ cmd_known\n");
595    }
596
597    #[test]
598    fn tool_output_finalizes_correct_message_by_id() {
599        let mut app = make_app();
600        push_tool_msg(&mut app, "t1");
601        push_tool_msg(&mut app, "t2");
602
603        // Finalize t1 with ToolOutput.
604        app.handle_agent_event(AgentEvent::ToolOutput {
605            tool_name: "bash".into(),
606            command: "$ cmd_t1\n".into(),
607            output: "final-output-t1".to_owned(),
608            success: true,
609            diff: None,
610            filter_stats: None,
611            kept_lines: None,
612            tool_call_id: "t1".to_owned(),
613        });
614
615        let msgs = app.messages();
616        assert_eq!(msgs.len(), 2);
617        // t1 must be finalized (not streaming) with the canonical output.
618        assert!(!msgs[0].streaming);
619        assert!(msgs[0].content.contains("final-output-t1"));
620        // t2 must still be streaming and unchanged.
621        assert!(msgs[1].streaming);
622        assert_eq!(msgs[1].content, "$ cmd_t2\n");
623    }
624
625    #[test]
626    fn diff_ready_attaches_to_matching_id() {
627        let mut app = make_app();
628        app.sessions.current_mut().messages.push(tool_msg("call-1"));
629        app.sessions.current_mut().messages.push(tool_msg("call-2"));
630
631        app.handle_agent_event(AgentEvent::DiffReady {
632            diff: diff(),
633            tool_call_id: "call-2".into(),
634        });
635
636        assert!(app.sessions.current().messages[0].diff_data.is_none());
637        assert!(app.sessions.current().messages[1].diff_data.is_some());
638    }
639
640    #[test]
641    fn diff_ready_mismatched_id_does_not_attach() {
642        let mut app = make_app();
643        app.sessions.current_mut().messages.push(tool_msg("call-1"));
644
645        app.handle_agent_event(AgentEvent::DiffReady {
646            diff: diff(),
647            tool_call_id: "call-99".into(),
648        });
649
650        assert!(app.sessions.current().messages[0].diff_data.is_none());
651    }
652
653    #[test]
654    fn diff_ready_empty_id_does_not_attach() {
655        let mut app = make_app();
656        app.sessions.current_mut().messages.push(tool_msg("call-1"));
657
658        app.handle_agent_event(AgentEvent::DiffReady {
659            diff: diff(),
660            tool_call_id: String::new(),
661        });
662
663        assert!(app.sessions.current().messages[0].diff_data.is_none());
664    }
665
666    #[test]
667    fn diff_ready_two_concurrent_attach_to_correct_messages() {
668        let mut app = make_app();
669        app.sessions.current_mut().messages.push(tool_msg("call-A"));
670        app.sessions.current_mut().messages.push(tool_msg("call-B"));
671        app.sessions.current_mut().messages.push(tool_msg("call-C"));
672
673        let diff_a = DiffData {
674            file_path: "a.rs".into(),
675            old_content: "old_a".into(),
676            new_content: "new_a".into(),
677        };
678        let diff_b = DiffData {
679            file_path: "b.rs".into(),
680            old_content: "old_b".into(),
681            new_content: "new_b".into(),
682        };
683
684        // Deliver out of order: B first, then A
685        app.handle_agent_event(AgentEvent::DiffReady {
686            diff: diff_b,
687            tool_call_id: "call-B".into(),
688        });
689        app.handle_agent_event(AgentEvent::DiffReady {
690            diff: diff_a,
691            tool_call_id: "call-A".into(),
692        });
693
694        let msgs = &app.sessions.current().messages;
695        assert_eq!(
696            msgs[0].diff_data.as_ref().map(|d| d.file_path.as_str()),
697            Some("a.rs"),
698            "call-A diff must attach to message 0"
699        );
700        assert_eq!(
701            msgs[1].diff_data.as_ref().map(|d| d.file_path.as_str()),
702            Some("b.rs"),
703            "call-B diff must attach to message 1"
704        );
705        assert!(
706            msgs[2].diff_data.is_none(),
707            "call-C must remain without diff"
708        );
709    }
710
711    #[test]
712    fn foreground_subagent_started_switches_view_to_subagent() {
713        let mut app = make_app();
714        assert!(app.sessions.current().view_target.is_main());
715
716        app.handle_agent_event(AgentEvent::ForegroundSubagentStarted {
717            id: "sa-001".into(),
718            name: "planner".into(),
719        });
720
721        assert_eq!(
722            app.sessions.current().view_target.subagent_id(),
723            Some("sa-001"),
724            "view must switch to the started subagent"
725        );
726        assert_eq!(
727            app.sessions.current().status_label.as_deref(),
728            Some("Sub-agent 'planner' running...")
729        );
730    }
731
732    #[test]
733    fn foreground_subagent_completed_switches_back_when_viewing_subagent() {
734        let mut app = make_app();
735
736        app.handle_agent_event(AgentEvent::ForegroundSubagentStarted {
737            id: "sa-002".into(),
738            name: "coder".into(),
739        });
740        assert_eq!(
741            app.sessions.current().view_target.subagent_id(),
742            Some("sa-002")
743        );
744
745        app.handle_agent_event(AgentEvent::ForegroundSubagentCompleted {
746            id: "sa-002".into(),
747            name: "coder".into(),
748            success: true,
749        });
750
751        assert!(
752            app.sessions.current().view_target.is_main(),
753            "view must return to Main after completion"
754        );
755        assert_eq!(
756            app.sessions.current().status_label.as_deref(),
757            Some("Sub-agent 'coder' completed")
758        );
759        let system_msg = app
760            .sessions
761            .current()
762            .messages
763            .iter()
764            .find(|m| m.role == MessageRole::System);
765        assert!(
766            system_msg.is_some(),
767            "completion system message must be pushed"
768        );
769    }
770
771    #[test]
772    fn foreground_subagent_completed_respects_manual_navigation() {
773        let mut app = make_app();
774
775        app.handle_agent_event(AgentEvent::ForegroundSubagentStarted {
776            id: "sa-003".into(),
777            name: "researcher".into(),
778        });
779
780        // Simulate user manually navigating away to a different subagent.
781        app.set_view_target(AgentViewTarget::SubAgent {
782            id: "sa-other".into(),
783            name: "other".into(),
784        });
785
786        app.handle_agent_event(AgentEvent::ForegroundSubagentCompleted {
787            id: "sa-003".into(),
788            name: "researcher".into(),
789            success: false,
790        });
791
792        // View must NOT switch to Main because user is viewing a different subagent.
793        assert_eq!(
794            app.sessions.current().view_target.subagent_id(),
795            Some("sa-other"),
796            "user's manual navigation must be preserved"
797        );
798    }
799
800    #[test]
801    fn foreground_subagent_failed_shows_failed_label() {
802        let mut app = make_app();
803
804        app.handle_agent_event(AgentEvent::ForegroundSubagentStarted {
805            id: "sa-004".into(),
806            name: "builder".into(),
807        });
808        app.handle_agent_event(AgentEvent::ForegroundSubagentCompleted {
809            id: "sa-004".into(),
810            name: "builder".into(),
811            success: false,
812        });
813
814        assert_eq!(
815            app.sessions.current().status_label.as_deref(),
816            Some("Sub-agent 'builder' failed")
817        );
818    }
819
820    // Fast-completing subagents cause a Started then immediately Completed event.
821    // This results in a brief flash before returning to Main, which is acceptable.
822    #[test]
823    fn foreground_subagent_fast_complete_ends_on_main() {
824        let mut app = make_app();
825
826        app.handle_agent_event(AgentEvent::ForegroundSubagentStarted {
827            id: "sa-fast".into(),
828            name: "quick".into(),
829        });
830        app.handle_agent_event(AgentEvent::ForegroundSubagentCompleted {
831            id: "sa-fast".into(),
832            name: "quick".into(),
833            success: true,
834        });
835
836        assert!(app.sessions.current().view_target.is_main());
837    }
838
839    // ── #6570 background subagent completion while manually viewed via sidebar ─────────
840
841    #[test]
842    fn background_subagent_completed_switches_back_when_viewing_subagent() {
843        // Reproduces the stuck-transcript bug: a background subagent (`/agent bg`) is
844        // manually opened via the sidebar's Enter action (set_view_target, not the
845        // ForegroundSubagentStarted event), then finishes. Before #6570, nothing ever told
846        // the TUI the viewed subagent had reached a terminal state — maybe_reload_transcript
847        // relies on `metrics.sub_agents`, which the manager empties synchronously at
848        // completion (SubAgentManager::collect), so the transcript pane was left frozen
849        // forever with no completion notice or reset to Main.
850        let mut app = make_app();
851        app.set_view_target(AgentViewTarget::SubAgent {
852            id: "sa-bg-1".into(),
853            name: "researcher".into(),
854        });
855        assert_eq!(
856            app.sessions.current().view_target.subagent_id(),
857            Some("sa-bg-1")
858        );
859
860        app.handle_agent_event(AgentEvent::BackgroundSubagentCompleted {
861            id: "sa-bg-1".into(),
862            name: "researcher".into(),
863            success: true,
864        });
865
866        assert!(
867            app.sessions.current().view_target.is_main(),
868            "view must return to Main once the manually-viewed background subagent completes"
869        );
870        assert_eq!(
871            app.sessions.current().status_label.as_deref(),
872            Some("Sub-agent 'researcher' completed")
873        );
874        let system_msg = app
875            .sessions
876            .current()
877            .messages
878            .iter()
879            .find(|m| m.role == MessageRole::System);
880        assert!(
881            system_msg.is_some(),
882            "a terminal completion marker must be pushed into the chat"
883        );
884    }
885
886    #[test]
887    fn background_subagent_completed_shows_failed_label_when_viewing_subagent() {
888        let mut app = make_app();
889        app.set_view_target(AgentViewTarget::SubAgent {
890            id: "sa-bg-2".into(),
891            name: "worker".into(),
892        });
893
894        app.handle_agent_event(AgentEvent::BackgroundSubagentCompleted {
895            id: "sa-bg-2".into(),
896            name: "worker".into(),
897            success: false,
898        });
899
900        assert!(app.sessions.current().view_target.is_main());
901        assert_eq!(
902            app.sessions.current().status_label.as_deref(),
903            Some("Sub-agent 'worker' failed")
904        );
905    }
906
907    #[test]
908    fn background_subagent_completed_ignored_when_not_viewing_that_subagent() {
909        // A background subagent completing while the user is looking at Main (or a
910        // different subagent) must not interrupt the current view — its plain-text
911        // completion notice is already delivered to Main chat separately via
912        // `Channel::send` in `notify_completed_subagents`; this event must be a no-op here.
913        let mut app = make_app();
914        assert!(app.sessions.current().view_target.is_main());
915        let messages_before = app.sessions.current().messages.len();
916
917        app.handle_agent_event(AgentEvent::BackgroundSubagentCompleted {
918            id: "sa-bg-elsewhere".into(),
919            name: "other".into(),
920            success: true,
921        });
922
923        assert!(app.sessions.current().view_target.is_main());
924        assert_eq!(
925            app.sessions.current().messages.len(),
926            messages_before,
927            "no message should be pushed for a background subagent that isn't being viewed"
928        );
929        assert_eq!(app.sessions.current().status_label, None);
930    }
931
932    #[test]
933    fn background_subagent_completed_respects_different_subagent_being_viewed() {
934        let mut app = make_app();
935        app.set_view_target(AgentViewTarget::SubAgent {
936            id: "sa-other".into(),
937            name: "other".into(),
938        });
939
940        app.handle_agent_event(AgentEvent::BackgroundSubagentCompleted {
941            id: "sa-bg-3".into(),
942            name: "worker".into(),
943            success: true,
944        });
945
946        assert_eq!(
947            app.sessions.current().view_target.subagent_id(),
948            Some("sa-other"),
949            "viewing a different subagent must not be disturbed by an unrelated completion"
950        );
951    }
952
953    #[test]
954    fn context_estimate_updates_cached_value() {
955        let mut app = make_app();
956        assert_eq!(
957            app.context_token_estimate(),
958            0,
959            "initial estimate must be 0"
960        );
961
962        app.handle_agent_event(AgentEvent::ContextEstimate(14_200));
963        assert_eq!(app.context_token_estimate(), 14_200);
964
965        app.handle_agent_event(AgentEvent::ContextEstimate(512));
966        assert_eq!(
967            app.context_token_estimate(),
968            512,
969            "estimate must update on each event"
970        );
971    }
972}