1use 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
36pub 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
138pub struct App<W = tokio::process::ChildStdin> {
140 state: Arc<Mutex<TuiState>>,
141 terminated_rx: watch::Receiver<bool>,
144 interrupt_tx: Option<watch::Sender<bool>>,
148 rpc_writer: Option<RpcWriter<W>>,
150}
151
152impl App<tokio::process::ChildStdin> {
153 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 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 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 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 let mut events = EventStream::new();
220 let mut render_tick = interval(Duration::from_millis(16));
221
222 let mut viewport_height: usize = 24; loop {
226 tokio::select! {
228 biased;
229
230 maybe_event = events.next() => {
232 match maybe_event {
233 Some(Ok(event)) => {
234 match event {
235 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 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 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 {
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 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(); 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 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 {
339 let mut state = self.state.lock().unwrap();
340 if state.show_help {
341 state.show_help = false;
342 continue;
343 }
344 }
345
346 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 _ => {}
361 }
362 }
363 Some(Err(e)) => {
364 tracing::warn!("Event stream error: {}", e);
366 }
367 None => {
368 break;
370 }
371 }
372 }
373
374 _ = 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), Constraint::Min(0), Constraint::Length(2), ])
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 state.clear_expired_guidance_flash();
394
395 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; terminal.draw(|f| {
413 f.render_widget(header::render(&state, chunks[0].width), chunks[0]);
415
416 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 f.render_widget(footer::render(&state), chunks[2]);
432
433 if state.show_help {
435 help::render(f, f.area());
436 }
437 })?;
438 }
439
440 _ = self.terminated_rx.changed() => {
442 if *self.terminated_rx.borrow() {
443 break;
444 }
445 }
446 }
447 }
448
449 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 #[test]
468 fn dispatch_action_scroll_down_calls_scroll_down_on_current_buffer() {
469 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 dispatch_action(Action::ScrollDown, &mut state, 10);
481
482 assert_eq!(
484 state.current_iteration().unwrap().scroll_offset,
485 1,
486 "scroll_down should increment scroll_offset"
487 );
488 }
489
490 #[test]
495 fn j_key_triggers_scroll_down_action() {
496 let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
498
499 let action = map_key(key);
501
502 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 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 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 #[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 #[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 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 #[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 #[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}