Skip to main content

recursive/tui/app/
event_loop.rs

1//! Event-loop reducers: `handle_ui_event` and streaming helpers.
2
3use crate::tui::events::UiEvent;
4use crate::tui::ui::transcript::render_block;
5
6use super::render::extract_write_file_path_from_result;
7use super::{
8    parse_apply_patch_input, preview_args, verb_for_tool, App, ToolResultData, TranscriptBlock,
9};
10
11impl App {
12    /// Apply an event coming from the backend worker.
13    pub fn handle_ui_event(&mut self, event: UiEvent) {
14        match event {
15            UiEvent::AssistantPartial { text } => {
16                self.append_streaming_assistant(&text);
17            }
18            UiEvent::Reasoning { content } => {
19                // Each step's reasoning arrives as a single block.
20                // Push a new TranscriptBlock::Reasoning; the renderer
21                // draws a `thinking…` header followed by the
22                // reasoning text in dim grey italics.
23                self.blocks
24                    .push(TranscriptBlock::Reasoning { text: content });
25            }
26            UiEvent::AssistantMessage { content } => {
27                // Goal-147: the legacy `"plan:"` / `"## plan"` text
28                // sniff is gone — plan-mode now arrives through the
29                // structured `UiEvent::PlanProposed` channel. Any
30                // assistant text that looks like a plan prefix is now
31                // just displayed as-is.
32                self.finalise_streaming_assistant(content);
33            }
34            UiEvent::ToolCall {
35                id,
36                name,
37                arguments,
38            } => {
39                let preview = preview_args(&arguments);
40                // Try to also synthesise a Diff block when the tool
41                // looks like an edit. For apply_patch we'll create the
42                // Diff alongside the ToolCall; for write_file we wait
43                // for the ToolResult so the byte count is accurate.
44                self.blocks.push(TranscriptBlock::ToolCall {
45                    id: id.clone(),
46                    name: name.clone(),
47                    args_preview: preview,
48                    result: None,
49                });
50                if name == "apply_patch" {
51                    if let Some((path, hunks)) = parse_apply_patch_input(&arguments) {
52                        self.blocks.push(TranscriptBlock::Diff { path, hunks });
53                    }
54                }
55                // Refine spinner verb based on tool category.
56                self.turn.spinner_verb = verb_for_tool(&name);
57            }
58            UiEvent::ToolResult {
59                id,
60                name,
61                output,
62                success,
63            } => {
64                // For write_file, render a synthesised Diff stub
65                // ("Created/Updated path (N bytes)") alongside the
66                // existing ToolCall. We also stamp the ToolCall as
67                // completed (empty output) so its bullet turns green
68                // instead of staying yellow. The Diff block below
69                // carries the actual file change.
70                if name == "write_file" && success {
71                    if let Some(path) = extract_write_file_path_from_result(&output) {
72                        self.blocks.push(TranscriptBlock::Diff {
73                            path,
74                            hunks: vec![],
75                        });
76                        if let Some(TranscriptBlock::ToolCall { result, .. }) = self
77                            .blocks
78                            .iter_mut()
79                            .rev()
80                            .find(|b| {
81                                matches!(b, TranscriptBlock::ToolCall { id: cid, .. } if cid == &id)
82                            }) {
83                            *result = Some(ToolResultData {
84                                success: true,
85                                output: String::new(),
86                                expanded: false,
87                            });
88                        }
89                        return;
90                    }
91                }
92                // Find the matching ToolCall (most recent first) and
93                // fill in its result. Falls back to pushing a new
94                // ToolCall block if no match is found — this can
95                // happen when ToolResult arrives before ToolCall
96                // (shouldn't, but be defensive).
97                let mut filled = false;
98                for block in self.blocks.iter_mut().rev() {
99                    if let TranscriptBlock::ToolCall {
100                        id: cid,
101                        result,
102                        name: cname,
103                        args_preview,
104                    } = block
105                    {
106                        if cid == &id {
107                            *result = Some(ToolResultData {
108                                success,
109                                output: output.clone(),
110                                expanded: false,
111                            });
112                            // Backfill name/args if the matching
113                            // ToolCall was synthesised by the
114                            // fallback path.
115                            if cname.is_empty() {
116                                *cname = name.clone();
117                            }
118                            if args_preview.is_empty() {
119                                *args_preview = String::new();
120                            }
121                            filled = true;
122                            break;
123                        }
124                    }
125                }
126                if !filled {
127                    self.blocks.push(TranscriptBlock::ToolCall {
128                        id,
129                        name,
130                        args_preview: String::new(),
131                        result: Some(ToolResultData {
132                            success,
133                            output,
134                            expanded: false,
135                        }),
136                    });
137                }
138            }
139            UiEvent::Usage {
140                input_tokens,
141                output_tokens,
142            } => {
143                self.usage.record(input_tokens, output_tokens);
144            }
145            UiEvent::Latency { llm_ms } => {
146                self.usage.last_latency_ms = llm_ms;
147                self.pending_latency_ms = Some(llm_ms);
148                // Stamp any in-flight streaming assistant block.
149                if let Some(TranscriptBlock::Assistant {
150                    streaming: true,
151                    latency_ms,
152                    ..
153                }) = self.blocks.last_mut()
154                {
155                    *latency_ms = Some(llm_ms);
156                }
157            }
158            UiEvent::Compacted { removed, kept } => {
159                self.blocks
160                    .push(TranscriptBlock::Compacted { removed, kept });
161            }
162            UiEvent::TurnFinished => {
163                // Make sure the last streaming assistant block is
164                // closed in case the provider never emitted a final
165                // AssistantText (some providers stream tokens then
166                // stop without a synthesised final).
167                if let Some(TranscriptBlock::Assistant { streaming, .. }) = self.blocks.last_mut() {
168                    *streaming = false;
169                }
170                self.turn.finish();
171                self.pending_latency_ms = None;
172            }
173            UiEvent::Error { message } => {
174                self.blocks.push(TranscriptBlock::Error {
175                    text: format!("Error: {message}"),
176                });
177            }
178            UiEvent::PlanProposed {
179                plan_text,
180                tool_calls,
181            } => {
182                // Fix-E: show the plan inline in the transcript rather
183                // than as a floating modal.  The dedicated
184                // `TranscriptBlock::PlanProposal` variant is rendered
185                // as a bordered box inside the message stream, and the
186                // status bar already shows "plan: y/n" to signal that
187                // the input layer is awaiting a decision.
188                self.blocks.push(TranscriptBlock::PlanProposal {
189                    plan_text,
190                    tool_calls,
191                });
192                self.plan_awaiting_approval = true;
193            }
194            UiEvent::PlanConfirmed => {
195                self.close_plan_review_modal();
196                self.blocks.push(TranscriptBlock::System {
197                    text: "Plan approved".into(),
198                });
199                self.plan_awaiting_approval = false;
200            }
201            UiEvent::PlanRejected { reason } => {
202                self.close_plan_review_modal();
203                self.blocks.push(TranscriptBlock::System {
204                    text: format!("Plan rejected: {reason}"),
205                });
206                self.plan_awaiting_approval = false;
207            }
208
209            // ── Goal-202: plan-mode pre-confirmation events ─────────────────
210            UiEvent::PlanModeRequested { reason } => {
211                // Render the request inline in the transcript so the user can
212                // read the reason and decide without a modal obscuring context.
213                self.blocks.push(TranscriptBlock::PlanModeRequest {
214                    reason,
215                    approved: None,
216                });
217                self.plan_mode_request_pending = true;
218            }
219            UiEvent::PlanModeApproved => {
220                // Mark the last PlanModeRequest block as approved.
221                for block in self.blocks.iter_mut().rev() {
222                    if let TranscriptBlock::PlanModeRequest { approved, .. } = block {
223                        *approved = Some(true);
224                        break;
225                    }
226                }
227                self.plan_mode_request_pending = false;
228            }
229            UiEvent::PlanModeRejected { reason: _ } => {
230                // Mark the last PlanModeRequest block as rejected.
231                for block in self.blocks.iter_mut().rev() {
232                    if let TranscriptBlock::PlanModeRequest { approved, .. } = block {
233                        *approved = Some(false);
234                        break;
235                    }
236                }
237                self.plan_mode_request_pending = false;
238            }
239
240            // Goal-167: replace the task list whenever the agent calls todo_write.
241            UiEvent::TodoUpdated { todos } => {
242                self.current_todos = todos;
243            }
244
245            // ── Goal-168: goal-loop events ──────────────────────────────────
246            UiEvent::GoalContinuing { reason, turns } => {
247                self.blocks.push(TranscriptBlock::System {
248                    text: format!("Goal continuing (turn {turns}): {reason}"),
249                });
250                // Update mirrored state.
251                if let Some(ref mut gs) = self.active_goal {
252                    gs.turns = turns;
253                    gs.last_reason = Some(reason);
254                }
255            }
256            UiEvent::GoalAchieved { condition, turns } => {
257                self.blocks.push(TranscriptBlock::System {
258                    text: format!("Goal achieved after {turns} turns: \"{condition}\""),
259                });
260                self.active_goal = None;
261            }
262            UiEvent::GoalCleared => {
263                self.blocks.push(TranscriptBlock::System {
264                    text: "Goal cleared.".into(),
265                });
266                self.active_goal = None;
267            }
268            // ── Goal-170: turn abort ──────────────────────────────────────────
269            UiEvent::Interrupted => {
270                self.blocks.push(TranscriptBlock::System {
271                    text: "[Interrupted]".into(),
272                });
273                self.turn.finish();
274                self.pending_latency_ms = None;
275                self.plan_awaiting_approval = false;
276            }
277            UiEvent::McpServersLoaded { entries } => {
278                self.push_modal(crate::tui::ui::modal::Modal::McpServers {
279                    entries,
280                    selected: 0,
281                });
282            }
283            UiEvent::SessionResumed {
284                session_id,
285                turn_count,
286            } => {
287                self.blocks.push(TranscriptBlock::System {
288                    text: format!("▶ Resumed session {session_id} ({turn_count} messages)"),
289                });
290                self.turn.finish();
291                self.scroll_to_bottom();
292            }
293
294            // Goal-210: hook progress events → status-bar style System blocks.
295            UiEvent::HookStarted {
296                hook_event,
297                hook_name,
298                ..
299            } => {
300                self.blocks.push(TranscriptBlock::System {
301                    text: format!("⚡ hook [{hook_event}] {hook_name} started"),
302                });
303                self.scroll_to_bottom();
304            }
305            UiEvent::HookProgress {
306                hook_name,
307                last_line,
308                ..
309            } => {
310                // Update the last System block if it was a hook block; otherwise push a new one.
311                let hook_prefix = "⚡ hook".to_string();
312                let should_update = self
313                    .blocks
314                    .last()
315                    .map(|b| matches!(b, TranscriptBlock::System { text } if text.starts_with(&hook_prefix)))
316                    .unwrap_or(false);
317                let text = format!("⚡ hook {hook_name}: {last_line}");
318                if should_update {
319                    if let Some(TranscriptBlock::System { text: t }) = self.blocks.last_mut() {
320                        *t = text;
321                    }
322                } else {
323                    self.blocks.push(TranscriptBlock::System { text });
324                }
325                self.scroll_to_bottom();
326            }
327            UiEvent::HookFinished {
328                hook_event,
329                hook_name,
330                outcome,
331                duration_ms,
332            } => {
333                self.blocks.push(TranscriptBlock::System {
334                    text: format!(
335                        "✓ hook [{hook_event}] {hook_name} → {outcome} ({duration_ms}ms)"
336                    ),
337                });
338                self.scroll_to_bottom();
339            }
340            UiEvent::HookSystemMessage { text } => {
341                self.blocks.push(TranscriptBlock::System { text });
342                self.scroll_to_bottom();
343            }
344
345            #[cfg(feature = "weixin")]
346            UiEvent::WeixinMessage { user_id, text } => {
347                self.blocks
348                    .push(TranscriptBlock::WeixinMessage { user_id, text });
349                self.scroll_to_bottom();
350            }
351        }
352        // Sticky-scroll: when the user is already at the bottom
353        // (scroll_offset == 0), keep them pinned as new content
354        // arrives. If they've explicitly scrolled up (Shift+↑ /
355        // PgUp set scroll_offset > 0), preserve their position so
356        // streaming tokens don't yank them back down mid-read.
357        if self.scroll_offset == 0 {
358            self.scroll_to_bottom();
359        }
360    }
361
362    /// If the topmost modal is a `PlanReview`, pop it. No-op
363    /// otherwise — the runtime may emit `PlanConfirmed` after the
364    /// user already dismissed the modal manually, in which case we
365    /// only want to push the System block.
366    fn close_plan_review_modal(&mut self) {
367        if matches!(
368            self.modals.last(),
369            Some(crate::tui::ui::modal::Modal::PlanReview { .. })
370        ) {
371            self.modals.pop();
372        }
373    }
374
375    pub(crate) fn start_turn(&mut self) {
376        self.turn.start();
377        self.turn_count = self.turn_count.saturating_add(1);
378    }
379
380    fn append_streaming_assistant(&mut self, chunk: &str) {
381        if let Some(TranscriptBlock::Assistant {
382            text,
383            streaming: true,
384            ..
385        }) = self.blocks.last_mut()
386        {
387            text.push_str(chunk);
388        } else {
389            self.blocks.push(TranscriptBlock::Assistant {
390                text: chunk.to_string(),
391                streaming: true,
392                latency_ms: self.pending_latency_ms,
393            });
394        }
395    }
396
397    fn finalise_streaming_assistant(&mut self, content: String) {
398        if let Some(TranscriptBlock::Assistant {
399            text,
400            streaming,
401            latency_ms,
402        }) = self.blocks.last_mut()
403        {
404            if *streaming {
405                *text = content;
406                *streaming = false;
407                if latency_ms.is_none() {
408                    *latency_ms = self.pending_latency_ms;
409                }
410                return;
411            }
412        }
413        self.blocks.push(TranscriptBlock::Assistant {
414            text: content,
415            streaming: false,
416            latency_ms: self.pending_latency_ms,
417        });
418    }
419
420    /// Scan `blocks[last_printed_idx..]` and push any "finalized"
421    /// blocks into `print_queue` so the main loop can flush them to
422    /// the terminal's scrollback buffer via `terminal.insert_before()`.
423    ///
424    /// A block is considered finalized when:
425    /// - `User` — always
426    /// - `Assistant` — when `!streaming`
427    /// - `ToolCall` — when `result.is_some()`
428    /// - `Reasoning` — only when the immediately following block is NOT
429    ///   a streaming `Assistant` (prevents the reasoning from being
430    ///   separated from its answer in the scrollback)
431    /// - All other variants — always
432    ///
433    /// This is idempotent and safe to call after every event.
434    pub fn flush_ready_blocks(&mut self) {
435        loop {
436            let i = self.last_printed_idx;
437            if i >= self.blocks.len() {
438                break;
439            }
440            let ready = match &self.blocks[i] {
441                TranscriptBlock::User { .. } => true,
442                TranscriptBlock::Assistant { streaming, .. } => !streaming,
443                TranscriptBlock::ToolCall { result, .. } => result.is_some(),
444                TranscriptBlock::Reasoning { .. } => {
445                    // Defer until the following Assistant is finalized so
446                    // reasoning and its answer always appear together.
447                    !matches!(
448                        self.blocks.get(i + 1),
449                        Some(TranscriptBlock::Assistant {
450                            streaming: true,
451                            ..
452                        })
453                    )
454                }
455                _ => true,
456            };
457            if !ready {
458                break;
459            }
460            let is_user = matches!(&self.blocks[i], TranscriptBlock::User { .. });
461            let is_first = i == 0;
462
463            // Mirror the spacing from render_blocks(): add blank lines between
464            // blocks and extra blank lines around User blocks so the scrollback
465            // has the same visual breathing room as the live viewport.
466            if !is_first {
467                let mut pre: Vec<ratatui::text::Line<'static>> = vec![ratatui::text::Line::raw("")];
468                if is_user {
469                    // Extra blank before User turns.
470                    pre.push(ratatui::text::Line::raw(""));
471                }
472                self.print_queue.push(pre);
473            }
474
475            let lines = render_block(&self.blocks[i], self.theme);
476            self.print_queue.push(lines);
477
478            // Extra blank after User turns (same as render_blocks).
479            if is_user {
480                self.print_queue.push(vec![ratatui::text::Line::raw("")]);
481            }
482
483            self.last_printed_idx += 1;
484        }
485    }
486
487    /// Toggle the most recent completed tool call's expanded flag.
488    /// Walks back over `ToolCall` blocks that have a `result` (i.e.
489    /// the tool has finished) and flips its `expanded` field. Tool
490    /// calls still running (no result yet) are skipped.
491    pub(crate) fn toggle_last_expandable(&mut self) {
492        for block in self.blocks.iter_mut().rev() {
493            if let TranscriptBlock::ToolCall {
494                result: Some(ToolResultData { expanded, .. }),
495                ..
496            } = block
497            {
498                *expanded = !*expanded;
499                return;
500            }
501        }
502    }
503}
504
505// ──────────────────────────────────────────────────────────────────────
506// Tests
507// ──────────────────────────────────────────────────────────────────────
508
509#[cfg(test)]
510mod tests {
511    use crate::tui::app::{App, AppScreen, TranscriptBlock};
512    use crate::tui::events::UiEvent;
513
514    // ── streaming assistant ────────────────────────────────────────
515
516    #[test]
517    fn transcript_apply_partial_token_appends_to_streaming_assistant() {
518        let mut app = App::new();
519        app.screen = AppScreen::Chat;
520        app.handle_ui_event(UiEvent::AssistantPartial { text: "hel".into() });
521        app.handle_ui_event(UiEvent::AssistantPartial { text: "lo".into() });
522
523        match app.blocks.last() {
524            Some(TranscriptBlock::Assistant {
525                text, streaming, ..
526            }) => {
527                assert_eq!(text, "hello");
528                assert!(*streaming);
529            }
530            other => panic!("expected streaming Assistant, got {other:?}"),
531        }
532    }
533
534    #[test]
535    fn transcript_apply_assistant_text_finalizes_streaming() {
536        let mut app = App::new();
537        app.screen = AppScreen::Chat;
538        app.handle_ui_event(UiEvent::AssistantPartial { text: "hel".into() });
539        app.handle_ui_event(UiEvent::AssistantMessage {
540            content: "hello world".into(),
541        });
542
543        match app.blocks.last() {
544            Some(TranscriptBlock::Assistant {
545                text, streaming, ..
546            }) => {
547                assert_eq!(text, "hello world");
548                assert!(!*streaming);
549            }
550            other => panic!("expected finalised Assistant, got {other:?}"),
551        }
552    }
553
554    #[test]
555    fn transcript_assistant_text_without_prior_stream_creates_block() {
556        let mut app = App::new();
557        app.screen = AppScreen::Chat;
558        app.handle_ui_event(UiEvent::AssistantMessage {
559            content: "single shot".into(),
560        });
561        match app.blocks.last() {
562            Some(TranscriptBlock::Assistant {
563                text, streaming, ..
564            }) => {
565                assert_eq!(text, "single shot");
566                assert!(!*streaming);
567            }
568            other => panic!("expected non-streaming Assistant, got {other:?}"),
569        }
570    }
571
572    // ── tool call / result ─────────────────────────────────────────
573
574    #[test]
575    fn tool_call_and_result_pair_by_id() {
576        let mut app = App::new();
577        app.screen = AppScreen::Chat;
578        app.handle_ui_event(UiEvent::ToolCall {
579            id: "abc".into(),
580            name: "read_file".into(),
581            arguments: r#"{"path":"src/agent.rs"}"#.into(),
582        });
583        app.handle_ui_event(UiEvent::ToolResult {
584            id: "abc".into(),
585            name: "read_file".into(),
586            output: "ok".into(),
587            success: true,
588        });
589
590        // ToolResult now merges into the matching ToolCall block
591        // rather than producing a sibling ToolResult block. We
592        // expect exactly one ToolCall block for "abc" with a
593        // Some(result).
594        let tool_calls: Vec<_> = app
595            .blocks
596            .iter()
597            .filter(|b| matches!(b, TranscriptBlock::ToolCall { id, .. } if id == "abc"))
598            .collect();
599        assert_eq!(tool_calls.len(), 1, "ToolResult must merge into ToolCall");
600        let block = tool_calls[0];
601        match block {
602            TranscriptBlock::ToolCall {
603                id,
604                result: Some(r),
605                ..
606            } => {
607                assert_eq!(id, "abc");
608                assert!(r.success);
609                assert_eq!(r.output, "ok");
610            }
611            other => panic!("expected ToolCall with Some(result), got {other:?}"),
612        }
613    }
614
615    #[test]
616    fn apply_patch_call_emits_diff_block() {
617        let mut app = App::new();
618        app.screen = AppScreen::Chat;
619        let patch = "*** Begin Patch\n*** Update File: src/foo.rs\n@@ pub fn bar()\n pub fn bar() {\n-    let x = 1;\n+    let x = 2;\n }\n*** End Patch";
620        let arguments = serde_json::json!({"input": patch}).to_string();
621        app.handle_ui_event(UiEvent::ToolCall {
622            id: "1".into(),
623            name: "apply_patch".into(),
624            arguments,
625        });
626        let has_diff = app
627            .blocks
628            .iter()
629            .any(|b| matches!(b, TranscriptBlock::Diff { path, .. } if path == "src/foo.rs"));
630        assert!(has_diff, "expected Diff block, got {:?}", app.blocks);
631    }
632
633    #[test]
634    fn write_file_result_renders_diff_block() {
635        let mut app = App::new();
636        app.screen = AppScreen::Chat;
637        app.handle_ui_event(UiEvent::ToolCall {
638            id: "1".into(),
639            name: "write_file".into(),
640            arguments: r#"{"path":"src/new.rs","contents":"x"}"#.into(),
641        });
642        app.handle_ui_event(UiEvent::ToolResult {
643            id: "1".into(),
644            name: "write_file".into(),
645            output: "Wrote 42 bytes to src/new.rs".into(),
646            success: true,
647        });
648        let has_diff = app.blocks.iter().any(
649            |b| matches!(b, TranscriptBlock::Diff { path, .. } if path.contains("src/new.rs")),
650        );
651        assert!(has_diff, "expected Diff block from write_file");
652    }
653
654    // ── compacted ──────────────────────────────────────────────────
655
656    #[test]
657    fn compacted_event_creates_compacted_block() {
658        let mut app = App::new();
659        app.screen = AppScreen::Chat;
660        app.handle_ui_event(UiEvent::Compacted {
661            removed: 12,
662            kept: 1,
663        });
664        assert!(matches!(
665            app.blocks.last(),
666            Some(TranscriptBlock::Compacted {
667                removed: 12,
668                kept: 1
669            })
670        ));
671    }
672
673    // ── usage stats ────────────────────────────────────────────────
674
675    #[test]
676    fn usage_stats_accumulate_across_turns() {
677        let mut app = App::new();
678        app.screen = AppScreen::Chat;
679        app.handle_ui_event(UiEvent::Usage {
680            input_tokens: 100,
681            output_tokens: 50,
682        });
683        app.handle_ui_event(UiEvent::Usage {
684            input_tokens: 30,
685            output_tokens: 20,
686        });
687        assert_eq!(app.usage.total_input, 130);
688        assert_eq!(app.usage.total_output, 70);
689        assert_eq!(app.usage.input_tokens, 30);
690        assert_eq!(app.usage.output_tokens, 20);
691    }
692
693    // ── error event ─────────────────────────────────────────────────
694
695    #[test]
696    fn error_event_pushes_error_block() {
697        let mut app = App::new();
698        app.screen = AppScreen::Chat;
699        app.handle_ui_event(UiEvent::Error {
700            message: "boom".into(),
701        });
702        assert!(matches!(
703            app.blocks.last(),
704            Some(TranscriptBlock::Error { text }) if text.contains("boom")
705        ));
706    }
707
708    // ── Plan Mode (Goal 147) ───────────────────────────────────────
709
710    #[test]
711    fn plan_proposed_event_opens_plan_review_modal() {
712        // Fix-E: PlanProposed now renders inline as a TranscriptBlock
713        // instead of opening a floating modal.
714        let mut app = App::new();
715        app.screen = AppScreen::Chat;
716        app.handle_ui_event(UiEvent::PlanProposed {
717            plan_text: "1. read_file\n2. apply_patch".into(),
718            tool_calls: vec![serde_json::json!({
719                "name": "read_file",
720                "id": "1",
721                "arguments": { "path": "src/foo.rs" }
722            })],
723        });
724        // No modal should be opened — plan is inline in the transcript.
725        assert!(app.modals.is_empty());
726        // The plan proposal block should be in the transcript.
727        assert!(app.blocks.iter().any(|b| matches!(b,
728            TranscriptBlock::PlanProposal { plan_text, .. }
729                if plan_text.contains("read_file"))));
730        assert!(app.plan_awaiting_approval);
731    }
732
733    #[test]
734    fn plan_confirmed_closes_modal_and_pushes_system_block() {
735        use crate::tui::ui::modal::Modal;
736        let mut app = App::new();
737        app.screen = AppScreen::Chat;
738        app.modals.push(Modal::PlanReview {
739            plan_text: "do".into(),
740            tool_calls: vec![],
741            edited_text: None,
742        });
743        app.handle_ui_event(UiEvent::PlanConfirmed);
744        assert!(app.modals.is_empty());
745        assert!(app
746            .blocks
747            .iter()
748            .any(|b| matches!(b, TranscriptBlock::System { text } if text == "Plan approved")));
749    }
750
751    #[test]
752    fn plan_rejected_pushes_system_block_with_reason() {
753        use crate::tui::ui::modal::Modal;
754        let mut app = App::new();
755        app.screen = AppScreen::Chat;
756        app.modals.push(Modal::PlanReview {
757            plan_text: "do".into(),
758            tool_calls: vec![],
759            edited_text: None,
760        });
761        app.handle_ui_event(UiEvent::PlanRejected {
762            reason: "user rejected".into(),
763        });
764        assert!(app.modals.is_empty());
765        assert!(app.blocks.iter().any(|b| matches!(b,
766            TranscriptBlock::System { text } if text == "Plan rejected: user rejected")));
767    }
768
769    // ── sticky-scroll ───────────────────────────────────────────────
770
771    /// Sticky-scroll: when the user has explicitly scrolled up,
772    /// new events should NOT yank them back to the bottom.
773    #[test]
774    fn new_event_keeps_scroll_offset_when_user_scrolled_up() {
775        let mut app = App::new();
776        app.screen = AppScreen::Chat;
777        app.scroll_offset = 5; // user pressed Ctrl+B / PgUp etc.
778        app.handle_ui_event(UiEvent::AssistantMessage {
779            content: "hello".into(),
780        });
781        assert_eq!(app.scroll_offset, 5);
782    }
783
784    /// Sticky-scroll counterpart: when the user is at the bottom,
785    /// new events DO scroll-to-bottom.
786    #[test]
787    fn new_event_at_bottom_keeps_user_at_bottom() {
788        let mut app = App::new();
789        app.screen = AppScreen::Chat;
790        app.scroll_offset = 0;
791        app.handle_ui_event(UiEvent::AssistantMessage {
792            content: "hello".into(),
793        });
794        assert_eq!(app.scroll_offset, 0);
795    }
796
797    // ── TurnFinished ────────────────────────────────────────────────
798
799    #[test]
800    fn turn_finished_stops_turn() {
801        let mut app = App::new();
802        app.screen = AppScreen::Chat;
803        app.set_input("hi");
804        app.handle_ui_event(UiEvent::TurnFinished);
805        assert!(!app.turn.running);
806    }
807}