Skip to main content

ralph_tui/
app.rs

1//! Main application loop for the TUI.
2//!
3//! This module provides a read-only observation dashboard that displays
4//! formatted output from the Ralph orchestrator, with iteration navigation,
5//! scroll, and search functionality.
6
7use crate::input::{Action, map_key};
8use crate::rpc_writer::RpcWriter;
9use crate::state::TuiState;
10use crate::update_check;
11use crate::widgets::{content::ContentPane, footer, header, help};
12use anyhow::Result;
13use crossterm::{
14    cursor::Show,
15    event::{
16        DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEventKind,
17        KeyModifiers, MouseEventKind,
18    },
19    execute,
20    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
21};
22use futures::StreamExt;
23use ratatui::{
24    Terminal,
25    backend::CrosstermBackend,
26    layout::{Constraint, Direction, Layout},
27};
28use scopeguard::defer;
29use std::io;
30use std::sync::{Arc, Mutex};
31use tokio::io::AsyncWrite;
32use tokio::sync::watch;
33use tokio::time::{Duration, interval};
34use tracing::info;
35
36/// Dispatches an action to the TuiState.
37///
38/// Returns `true` if the action signals to quit the application.
39pub fn dispatch_action(action: Action, state: &mut TuiState, viewport_height: usize) -> bool {
40    match action {
41        Action::Quit => return true,
42        Action::ScrollDown => {
43            if state.wave_view_active {
44                if let Some(buffer) = state.current_wave_worker_buffer_mut() {
45                    buffer.scroll_down(viewport_height);
46                }
47            } else if let Some(buffer) = state.current_iteration_mut() {
48                buffer.scroll_down(viewport_height);
49            }
50        }
51        Action::ScrollUp => {
52            if state.wave_view_active {
53                if let Some(buffer) = state.current_wave_worker_buffer_mut() {
54                    buffer.scroll_up();
55                }
56            } else if let Some(buffer) = state.current_iteration_mut() {
57                buffer.scroll_up();
58            }
59        }
60        Action::ScrollTop => {
61            if state.wave_view_active {
62                if let Some(buffer) = state.current_wave_worker_buffer_mut() {
63                    buffer.scroll_top();
64                }
65            } else if let Some(buffer) = state.current_iteration_mut() {
66                buffer.scroll_top();
67            }
68        }
69        Action::ScrollBottom => {
70            if state.wave_view_active {
71                if let Some(buffer) = state.current_wave_worker_buffer_mut() {
72                    buffer.scroll_bottom(viewport_height);
73                }
74            } else if let Some(buffer) = state.current_iteration_mut() {
75                buffer.scroll_bottom(viewport_height);
76            }
77        }
78        Action::NextIteration => {
79            if state.wave_view_active {
80                state.wave_view_next();
81            } else {
82                state.navigate_next();
83            }
84        }
85        Action::PrevIteration => {
86            if state.wave_view_active {
87                state.wave_view_prev();
88            } else {
89                state.navigate_prev();
90            }
91        }
92        Action::ShowHelp => {
93            state.show_help = true;
94        }
95        Action::DismissHelp => {
96            if state.wave_view_active {
97                state.exit_wave_view();
98            } else {
99                state.show_help = false;
100                state.clear_search();
101            }
102        }
103        Action::StartSearch => {
104            state.search_state.search_mode = true;
105        }
106        Action::SearchNext => {
107            state.next_match();
108        }
109        Action::SearchPrev => {
110            state.prev_match();
111        }
112        Action::GuidanceNext => {
113            state.start_guidance(crate::state::GuidanceMode::Next);
114        }
115        Action::GuidanceNow => {
116            state.start_guidance(crate::state::GuidanceMode::Now);
117        }
118        Action::EnterWaveView => {
119            state.enter_wave_view();
120        }
121        Action::ToggleMouseMode => {
122            state.mouse_capture_enabled = !state.mouse_capture_enabled;
123        }
124        Action::None => {}
125    }
126    false
127}
128
129fn set_mouse_capture(enabled: bool) -> Result<()> {
130    if enabled {
131        execute!(io::stdout(), EnableMouseCapture)?;
132    } else {
133        execute!(io::stdout(), DisableMouseCapture)?;
134    }
135    Ok(())
136}
137
138/// Main TUI application for read-only observation.
139pub struct App<W = tokio::process::ChildStdin> {
140    state: Arc<Mutex<TuiState>>,
141    /// Receives notification when the underlying process terminates.
142    /// This is the ONLY exit path for the TUI event loop (besides Action::Quit).
143    terminated_rx: watch::Receiver<bool>,
144    /// Channel to signal main loop on Ctrl+C.
145    /// In raw terminal mode, SIGINT is not generated, so TUI must signal
146    /// the main orchestration loop through this channel.
147    interrupt_tx: Option<watch::Sender<bool>>,
148    /// RPC writer for subprocess mode (replaces interrupt_tx for abort).
149    rpc_writer: Option<RpcWriter<W>>,
150}
151
152impl App<tokio::process::ChildStdin> {
153    /// Creates a new App with shared state, termination signal, and optional interrupt channel.
154    pub fn new(
155        state: Arc<Mutex<TuiState>>,
156        terminated_rx: watch::Receiver<bool>,
157        interrupt_tx: Option<watch::Sender<bool>>,
158    ) -> Self {
159        Self {
160            state,
161            terminated_rx,
162            interrupt_tx,
163            rpc_writer: None,
164        }
165    }
166}
167
168impl<W: AsyncWrite + Unpin + Send + 'static> App<W> {
169    /// Creates a new App for subprocess mode with an RPC writer.
170    pub fn new_subprocess(
171        state: Arc<Mutex<TuiState>>,
172        terminated_rx: watch::Receiver<bool>,
173        rpc_writer: RpcWriter<W>,
174    ) -> Self {
175        Self {
176            state,
177            terminated_rx,
178            interrupt_tx: None,
179            rpc_writer: Some(rpc_writer),
180        }
181    }
182
183    /// Runs the TUI event loop.
184    pub async fn run(mut self) -> Result<()> {
185        enable_raw_mode()?;
186        let mut stdout = io::stdout();
187        execute!(stdout, EnterAlternateScreen)?;
188        let backend = CrosstermBackend::new(stdout);
189        let mut terminal = Terminal::new(backend)?;
190        terminal.clear()?;
191
192        // CRITICAL: Ensure terminal cleanup on ANY exit path (normal, abort, or panic).
193        // When cleanup_tui() calls handle.abort(), the task is cancelled immediately
194        // at its current await point, skipping all code after the loop. This defer!
195        // guard runs on Drop, which is guaranteed even during task cancellation.
196        let cleanup_state = Arc::clone(&self.state);
197        defer! {
198            let _ = disable_raw_mode();
199            let mouse_capture_enabled = cleanup_state
200                .lock()
201                .map(|state| state.mouse_capture_enabled)
202                .unwrap_or(false);
203            if mouse_capture_enabled {
204                let _ = execute!(io::stdout(), DisableMouseCapture);
205            }
206            let _ = execute!(io::stdout(), LeaveAlternateScreen, Show);
207        }
208
209        let update_state = Arc::clone(&self.state);
210        tokio::spawn(async move {
211            let status = update_check::fetch_update_status().await;
212            if let Ok(mut state) = update_state.lock() {
213                state.set_update_status(status);
214            }
215        });
216
217        // Event-driven architecture: input polling is the primary driver
218        // Render is throttled to ~60fps via interval tick
219        let mut events = EventStream::new();
220        let mut render_tick = interval(Duration::from_millis(16));
221
222        // Track viewport height for scroll calculations
223        let mut viewport_height: usize = 24; // Default, updated on render
224
225        loop {
226            // Use biased select to prioritize input over render ticks
227            tokio::select! {
228                biased;
229
230                // Priority 1: Handle input events immediately for responsiveness
231                maybe_event = events.next() => {
232                    match maybe_event {
233                        Some(Ok(event)) => {
234                            match event {
235                                // Handle Ctrl+C: signal main loop and exit.
236                                // In raw mode, SIGINT is not generated, so we must signal the
237                                // main orchestration loop through interrupt_tx channel or RPC writer.
238                                Event::Key(key) if key.kind == KeyEventKind::Press
239                                    && key.code == KeyCode::Char('c')
240                                    && key.modifiers.contains(KeyModifiers::CONTROL) =>
241                                {
242                                    info!("Ctrl+C detected, signaling abort");
243                                    if let Some(ref writer) = self.rpc_writer {
244                                        // Subprocess mode: send abort via RPC
245                                        let writer = writer.clone();
246                                        tokio::spawn(async move {
247                                            let _ = writer.send_abort().await;
248                                        });
249                                    } else if let Some(ref tx) = self.interrupt_tx {
250                                        // In-process mode: signal via channel
251                                        let _ = tx.send(true);
252                                    }
253                                    break;
254                                }
255                                Event::Mouse(mouse) => {
256                                    match mouse.kind {
257                                        MouseEventKind::ScrollUp => {
258                                            let mut state = self.state.lock().unwrap();
259                                            let buffer = if state.wave_view_active {
260                                                state.current_wave_worker_buffer_mut()
261                                            } else {
262                                                state.current_iteration_mut()
263                                            };
264                                            if let Some(buffer) = buffer {
265                                                for _ in 0..3 {
266                                                    buffer.scroll_up();
267                                                }
268                                            }
269                                        }
270                                        MouseEventKind::ScrollDown => {
271                                            let mut state = self.state.lock().unwrap();
272                                            let buffer = if state.wave_view_active {
273                                                state.current_wave_worker_buffer_mut()
274                                            } else {
275                                                state.current_iteration_mut()
276                                            };
277                                            if let Some(buffer) = buffer {
278                                                for _ in 0..3 {
279                                                    buffer.scroll_down(viewport_height);
280                                                }
281                                            }
282                                        }
283                                        _ => {}
284                                    }
285                                }
286                                Event::Paste(text) => {
287                                    let mut state = self.state.lock().unwrap();
288                                    if state.is_guidance_active() {
289                                        state.guidance_input.push_str(&text);
290                                    }
291                                }
292                                Event::Key(key) if key.kind == KeyEventKind::Press => {
293                                    // Guidance input mode: intercept all keys
294                                    {
295                                        let mut state = self.state.lock().unwrap();
296                                        if state.is_guidance_active() {
297                                            match key.code {
298                                                KeyCode::Esc => {
299                                                    state.cancel_guidance();
300                                                }
301                                                KeyCode::Enter => {
302                                                    // In subprocess mode, send via RPC
303                                                    if let Some(ref writer) = self.rpc_writer {
304                                                        let message = state.guidance_input.trim().to_string();
305                                                        let mode = state.guidance_mode;
306                                                        state.cancel_guidance(); // Clear input
307                                                        if !message.is_empty() {
308                                                            let writer = writer.clone();
309                                                            tokio::spawn(async move {
310                                                                let _ = match mode {
311                                                                    Some(crate::state::GuidanceMode::Now) => {
312                                                                        writer.send_steer(&message).await
313                                                                    }
314                                                                    _ => {
315                                                                        writer.send_guidance(&message).await
316                                                                    }
317                                                                };
318                                                            });
319                                                        }
320                                                    } else {
321                                                        // In-process mode: use existing state method
322                                                        state.send_guidance();
323                                                    }
324                                                }
325                                                KeyCode::Backspace => {
326                                                    state.guidance_input.pop();
327                                                }
328                                                KeyCode::Char(c) => {
329                                                    state.guidance_input.push(c);
330                                                }
331                                                _ => {}
332                                            }
333                                            continue;
334                                        }
335                                    }
336
337                                    // Dismiss help on any key when help is showing
338                                    {
339                                        let mut state = self.state.lock().unwrap();
340                                        if state.show_help {
341                                            state.show_help = false;
342                                            continue;
343                                        }
344                                    }
345
346                                    // Map key to action and dispatch
347                                    let action = map_key(key);
348                                    let mut state = self.state.lock().unwrap();
349                                    let mouse_capture_enabled_before = state.mouse_capture_enabled;
350                                    if dispatch_action(action, &mut state, viewport_height) {
351                                        break;
352                                    }
353                                    let mouse_capture_enabled_after = state.mouse_capture_enabled;
354                                    drop(state);
355                                    if mouse_capture_enabled_before != mouse_capture_enabled_after {
356                                        set_mouse_capture(mouse_capture_enabled_after)?;
357                                    }
358                                }
359                                // Ignore other events (FocusGained, FocusLost, Paste, Resize, key releases)
360                                _ => {}
361                            }
362                        }
363                        Some(Err(e)) => {
364                            // Log error but continue - transient errors shouldn't crash TUI
365                            tracing::warn!("Event stream error: {}", e);
366                        }
367                        None => {
368                            // Stream ended unexpectedly
369                            break;
370                        }
371                    }
372                }
373
374                // Priority 2: Render at throttled rate (~60fps)
375                _ = render_tick.tick() => {
376                    let frame_size = terminal.size()?;
377                    let frame_area = ratatui::layout::Rect::new(0, 0, frame_size.width, frame_size.height);
378                    let chunks = Layout::default()
379                        .direction(Direction::Vertical)
380                        .constraints([
381                            Constraint::Length(2),  // Header: content + bottom border
382                            Constraint::Min(0),     // Content: flexible
383                            Constraint::Length(2),  // Footer: top border + content
384                        ])
385                        .split(frame_area);
386
387                    let content_area = chunks[1];
388                    viewport_height = content_area.height as usize;
389
390                    let mut state = self.state.lock().unwrap();
391
392                    // Clear expired flash messages (e.g., guidance send confirmation)
393                    state.clear_expired_guidance_flash();
394
395                    // Autoscroll: if user hasn't scrolled away, keep them at the bottom
396                    // as new content arrives. This mimics standard terminal behavior.
397                    if state.wave_view_active {
398                        if let Some(buffer) = state.current_wave_worker_buffer_mut()
399                            && buffer.following_bottom
400                        {
401                            let max_scroll = buffer.line_count().saturating_sub(viewport_height);
402                            buffer.scroll_offset = max_scroll;
403                        }
404                    } else if let Some(buffer) = state.current_iteration_mut()
405                        && buffer.following_bottom
406                    {
407                        let max_scroll = buffer.line_count().saturating_sub(viewport_height);
408                        buffer.scroll_offset = max_scroll;
409                    }
410
411                    let state = state; // Rebind as immutable for rendering
412                    terminal.draw(|f| {
413                        // Render header
414                        f.render_widget(header::render(&state, chunks[0].width), chunks[0]);
415
416                        // Render content: wave worker buffer when in wave view, else iteration
417                        let content_buffer = if state.wave_view_active {
418                            state.current_wave_worker_buffer()
419                        } else {
420                            state.current_iteration()
421                        };
422                        if let Some(buffer) = content_buffer {
423                            let mut content_widget = ContentPane::new(buffer);
424                            if let Some(query) = &state.search_state.query {
425                                content_widget = content_widget.with_search(query);
426                            }
427                            f.render_widget(content_widget, content_area);
428                        }
429
430                        // Render footer
431                        f.render_widget(footer::render(&state), chunks[2]);
432
433                        // Render help overlay if active
434                        if state.show_help {
435                            help::render(f, f.area());
436                        }
437                    })?;
438                }
439
440                // Priority 3: Handle termination signal
441                _ = self.terminated_rx.changed() => {
442                    if *self.terminated_rx.borrow() {
443                        break;
444                    }
445                }
446            }
447        }
448
449        // NOTE: Explicit cleanup removed - now handled by defer! guard above.
450        // The guard ensures cleanup happens even on task abort or panic.
451        Ok(())
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use crate::input::{Action, map_key};
459    use crate::state::TuiState;
460    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
461    use ratatui::text::Line;
462
463    // =========================================================================
464    // AC1: Events Reach State — TuiStreamHandler → IterationBuffer
465    // =========================================================================
466
467    #[test]
468    fn dispatch_action_scroll_down_calls_scroll_down_on_current_buffer() {
469        // Given TuiState with an iteration buffer containing content
470        let mut state = TuiState::new();
471        state.start_new_iteration();
472        let buffer = state.current_iteration_mut().unwrap();
473        for i in 0..20 {
474            buffer.append_line(Line::from(format!("line {}", i)));
475        }
476        let initial_offset = state.current_iteration().unwrap().scroll_offset;
477        assert_eq!(initial_offset, 0);
478
479        // When dispatch_action with ScrollDown and viewport_height 10
480        dispatch_action(Action::ScrollDown, &mut state, 10);
481
482        // Then scroll_offset is incremented
483        assert_eq!(
484            state.current_iteration().unwrap().scroll_offset,
485            1,
486            "scroll_down should increment scroll_offset"
487        );
488    }
489
490    // =========================================================================
491    // AC2: Keyboard Triggers Actions — 'j' → scroll_down()
492    // =========================================================================
493
494    #[test]
495    fn j_key_triggers_scroll_down_action() {
496        // Given key press 'j'
497        let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
498
499        // When map_key is called
500        let action = map_key(key);
501
502        // Then Action::ScrollDown is returned
503        assert_eq!(action, Action::ScrollDown);
504    }
505
506    #[test]
507    fn dispatch_action_scroll_up_calls_scroll_up_on_current_buffer() {
508        let mut state = TuiState::new();
509        state.start_new_iteration();
510        let buffer = state.current_iteration_mut().unwrap();
511        for i in 0..20 {
512            buffer.append_line(Line::from(format!("line {}", i)));
513        }
514        // Set initial scroll offset to 5
515        state.current_iteration_mut().unwrap().scroll_offset = 5;
516
517        dispatch_action(Action::ScrollUp, &mut state, 10);
518
519        assert_eq!(
520            state.current_iteration().unwrap().scroll_offset,
521            4,
522            "scroll_up should decrement scroll_offset"
523        );
524    }
525
526    #[test]
527    fn dispatch_action_scroll_top_jumps_to_top() {
528        let mut state = TuiState::new();
529        state.start_new_iteration();
530        let buffer = state.current_iteration_mut().unwrap();
531        for _ in 0..20 {
532            buffer.append_line(Line::from("line"));
533        }
534        state.current_iteration_mut().unwrap().scroll_offset = 10;
535
536        dispatch_action(Action::ScrollTop, &mut state, 10);
537
538        assert_eq!(state.current_iteration().unwrap().scroll_offset, 0);
539    }
540
541    #[test]
542    fn dispatch_action_scroll_bottom_jumps_to_bottom() {
543        let mut state = TuiState::new();
544        state.start_new_iteration();
545        let buffer = state.current_iteration_mut().unwrap();
546        for _ in 0..20 {
547            buffer.append_line(Line::from("line"));
548        }
549
550        dispatch_action(Action::ScrollBottom, &mut state, 10);
551
552        // max_scroll = 20 - 10 = 10
553        assert_eq!(state.current_iteration().unwrap().scroll_offset, 10);
554    }
555
556    #[test]
557    fn dispatch_action_next_iteration_navigates_forward() {
558        let mut state = TuiState::new();
559        state.start_new_iteration();
560        state.start_new_iteration();
561        state.start_new_iteration();
562        state.current_view = 0;
563        state.following_latest = false;
564
565        dispatch_action(Action::NextIteration, &mut state, 10);
566
567        assert_eq!(state.current_view, 1);
568    }
569
570    #[test]
571    fn dispatch_action_prev_iteration_navigates_backward() {
572        let mut state = TuiState::new();
573        state.start_new_iteration();
574        state.start_new_iteration();
575        state.start_new_iteration();
576        state.current_view = 2;
577
578        dispatch_action(Action::PrevIteration, &mut state, 10);
579
580        assert_eq!(state.current_view, 1);
581    }
582
583    #[test]
584    fn dispatch_action_show_help_sets_show_help() {
585        let mut state = TuiState::new();
586        assert!(!state.show_help);
587
588        dispatch_action(Action::ShowHelp, &mut state, 10);
589
590        assert!(state.show_help);
591    }
592
593    #[test]
594    fn dispatch_action_dismiss_help_clears_show_help() {
595        let mut state = TuiState::new();
596        state.show_help = true;
597
598        dispatch_action(Action::DismissHelp, &mut state, 10);
599
600        assert!(!state.show_help);
601    }
602
603    #[test]
604    fn dispatch_action_search_next_calls_next_match() {
605        let mut state = TuiState::new();
606        state.start_new_iteration();
607        let buffer = state.current_iteration_mut().unwrap();
608        buffer.append_line(Line::from("find me"));
609        buffer.append_line(Line::from("find me again"));
610        state.search("find");
611        assert_eq!(state.search_state.current_match, 0);
612
613        dispatch_action(Action::SearchNext, &mut state, 10);
614
615        assert_eq!(state.search_state.current_match, 1);
616    }
617
618    #[test]
619    fn dispatch_action_search_prev_calls_prev_match() {
620        let mut state = TuiState::new();
621        state.start_new_iteration();
622        let buffer = state.current_iteration_mut().unwrap();
623        buffer.append_line(Line::from("find me"));
624        buffer.append_line(Line::from("find me again"));
625        state.search("find");
626        state.search_state.current_match = 1;
627
628        dispatch_action(Action::SearchPrev, &mut state, 10);
629
630        assert_eq!(state.search_state.current_match, 0);
631    }
632
633    // =========================================================================
634    // AC5: Quit Returns True to Exit Loop
635    // =========================================================================
636
637    #[test]
638    fn dispatch_action_quit_returns_true() {
639        let mut state = TuiState::new();
640        let should_quit = dispatch_action(Action::Quit, &mut state, 10);
641        assert!(should_quit, "Quit action should return true to signal exit");
642    }
643
644    #[test]
645    fn dispatch_action_non_quit_returns_false() {
646        let mut state = TuiState::new();
647        state.start_new_iteration();
648        let buffer = state.current_iteration_mut().unwrap();
649        buffer.append_line(Line::from("line"));
650
651        let should_quit = dispatch_action(Action::ScrollDown, &mut state, 10);
652        assert!(!should_quit, "Non-quit actions should return false");
653    }
654
655    #[test]
656    fn dispatch_action_toggle_mouse_mode_flips_state() {
657        let mut state = TuiState::new();
658        assert!(!state.mouse_capture_enabled);
659
660        dispatch_action(Action::ToggleMouseMode, &mut state, 10);
661        assert!(state.mouse_capture_enabled);
662
663        dispatch_action(Action::ToggleMouseMode, &mut state, 10);
664        assert!(!state.mouse_capture_enabled);
665    }
666
667    // =========================================================================
668    // AC6: No PTY Code — Structural Test
669    // =========================================================================
670
671    #[test]
672    fn no_pty_handle_in_app() {
673        let source = include_str!("app.rs");
674        let test_module_start = source.find("#[cfg(test)]").unwrap_or(source.len());
675        let production_code = &source[..test_module_start];
676
677        // Check for PTY-related imports/code
678        assert!(
679            !production_code.contains("PtyHandle"),
680            "app.rs should not contain PtyHandle after refactor"
681        );
682        assert!(
683            !production_code.contains("tui_term"),
684            "app.rs should not contain tui_term references after refactor"
685        );
686        assert!(
687            !production_code.contains("TerminalWidget"),
688            "app.rs should not contain TerminalWidget after refactor"
689        );
690    }
691
692    /// Regression test: TUI must NOT have tokio::signal::ctrl_c() handler.
693    ///
694    /// Raw mode prevents SIGINT, so tokio's signal handler never fires.
695    /// TUI must detect Ctrl+C directly via crossterm events.
696    #[test]
697    fn no_tokio_signal_handler_in_app() {
698        let source = include_str!("app.rs");
699        let pattern = ["tokio", "::", "signal", "::", "ctrl_c", "()"].concat();
700        let test_module_start = source.find("#[cfg(test)]").unwrap_or(source.len());
701        let production_code = &source[..test_module_start];
702        let occurrences: Vec<_> = production_code.match_indices(&pattern).collect();
703        assert!(
704            occurrences.is_empty(),
705            "Found {} occurrence(s) of tokio::signal::ctrl_c() in production code. \
706             This doesn't work in raw mode - use crossterm events instead.",
707            occurrences.len()
708        );
709    }
710
711    /// Verify Ctrl+C handling exists in production code.
712    ///
713    /// Since raw mode prevents SIGINT, we must handle Ctrl+C via crossterm events.
714    /// TUI is observation-only, so Ctrl+C breaks out of the event loop.
715    #[test]
716    fn ctrl_c_handling_exists_in_app() {
717        let source = include_str!("app.rs");
718        let test_module_start = source.find("#[cfg(test)]").unwrap_or(source.len());
719        let production_code = &source[..test_module_start];
720
721        assert!(
722            production_code.contains("KeyCode::Char('c')")
723                && production_code.contains("KeyModifiers::CONTROL"),
724            "Production code must detect Ctrl+C via crossterm events"
725        );
726    }
727
728    #[test]
729    fn mouse_capture_starts_disabled_by_default() {
730        assert!(
731            !TuiState::new().mouse_capture_enabled,
732            "Production TUI should start with mouse capture disabled so native text selection works by default"
733        );
734    }
735}