async_inspect/tui/
mod.rs

1//! Terminal User Interface (TUI) for real-time async monitoring
2//!
3//! This module provides an interactive terminal dashboard for monitoring
4//! async tasks in real-time, similar to htop for processes.
5
6use crate::inspector::Inspector;
7use crate::task::{TaskInfo, TaskState};
8use crossterm::{
9    event::{
10        self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, MouseEvent, MouseEventKind,
11    },
12    execute,
13    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
14};
15use ratatui::{
16    backend::CrosstermBackend,
17    layout::{Constraint, Direction, Layout, Rect},
18    style::{Color, Modifier, Style, Stylize},
19    text::{Line, Span},
20    widgets::{Block, Borders, Paragraph, Row, Table},
21    Frame, Terminal,
22};
23use std::io;
24use std::time::{Duration, Instant};
25
26/// Sort mode for task list
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum SortMode {
29    /// Sort by task ID
30    Id,
31    /// Sort by task name
32    Name,
33    /// Sort by duration (slowest first)
34    Duration,
35    /// Sort by state
36    State,
37    /// Sort by poll count
38    PollCount,
39}
40
41/// Filter mode for tasks
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum FilterMode {
44    /// Show all tasks
45    All,
46    /// Show only running tasks
47    Running,
48    /// Show only completed tasks
49    Completed,
50    /// Show only failed tasks
51    Failed,
52    /// Show only blocked tasks
53    Blocked,
54}
55
56/// View mode for the TUI
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum ViewMode {
59    /// Task list view
60    TaskList,
61    /// Dependency graph view
62    DependencyGraph,
63}
64
65/// TUI application state
66pub struct TuiApp {
67    /// Inspector instance
68    inspector: Inspector,
69
70    /// Current view mode
71    view_mode: ViewMode,
72
73    /// Current sort mode
74    sort_mode: SortMode,
75
76    /// Current filter mode
77    filter_mode: FilterMode,
78
79    /// Selected task index
80    selected: usize,
81
82    /// Whether to show help
83    show_help: bool,
84
85    /// Search query
86    search_query: String,
87
88    /// Whether search is active
89    search_active: bool,
90
91    /// Last update time
92    last_update: Instant,
93
94    /// Update interval
95    update_interval: Duration,
96}
97
98impl TuiApp {
99    /// Create a new TUI application
100    #[must_use]
101    pub fn new(inspector: Inspector) -> Self {
102        Self {
103            inspector,
104            view_mode: ViewMode::TaskList,
105            sort_mode: SortMode::Duration,
106            filter_mode: FilterMode::All,
107            selected: 0,
108            show_help: false,
109            search_query: String::new(),
110            search_active: false,
111            last_update: Instant::now(),
112            update_interval: Duration::from_millis(100),
113        }
114    }
115
116    /// Set update interval
117    pub fn set_update_interval(&mut self, interval: Duration) {
118        self.update_interval = interval;
119    }
120
121    /// Get filtered and sorted tasks
122    fn get_tasks(&self) -> Vec<TaskInfo> {
123        let mut tasks = self.inspector.get_all_tasks();
124
125        // Apply search filter
126        if !self.search_query.is_empty() {
127            let query = self.search_query.to_lowercase();
128            tasks.retain(|task| {
129                task.name.to_lowercase().contains(&query)
130                    || format!("{}", task.id.as_u64()).contains(&query)
131            });
132        }
133
134        // Apply state filter
135        tasks.retain(|task| match self.filter_mode {
136            FilterMode::All => true,
137            FilterMode::Running => matches!(task.state, TaskState::Running),
138            FilterMode::Completed => matches!(task.state, TaskState::Completed),
139            FilterMode::Failed => matches!(task.state, TaskState::Failed),
140            FilterMode::Blocked => matches!(task.state, TaskState::Blocked { .. }),
141        });
142
143        // Apply sort
144        match self.sort_mode {
145            SortMode::Id => tasks.sort_by_key(|t| t.id.as_u64()),
146            SortMode::Name => tasks.sort_by(|a, b| a.name.cmp(&b.name)),
147            SortMode::Duration => tasks.sort_by(|a, b| b.age().cmp(&a.age())),
148            SortMode::State => {
149                tasks.sort_by(|a, b| format!("{:?}", a.state).cmp(&format!("{:?}", b.state)));
150            }
151            SortMode::PollCount => tasks.sort_by(|a, b| b.poll_count.cmp(&a.poll_count)),
152        }
153
154        tasks
155    }
156
157    /// Move selection up
158    fn select_previous(&mut self) {
159        if self.selected > 0 {
160            self.selected -= 1;
161        }
162    }
163
164    /// Move selection down
165    fn select_next(&mut self, max: usize) {
166        if self.selected < max.saturating_sub(1) {
167            self.selected += 1;
168        }
169    }
170
171    /// Cycle to next sort mode
172    fn next_sort_mode(&mut self) {
173        self.sort_mode = match self.sort_mode {
174            SortMode::Id => SortMode::Name,
175            SortMode::Name => SortMode::Duration,
176            SortMode::Duration => SortMode::State,
177            SortMode::State => SortMode::PollCount,
178            SortMode::PollCount => SortMode::Id,
179        };
180        self.selected = 0;
181    }
182
183    /// Cycle to next filter mode
184    fn next_filter_mode(&mut self) {
185        self.filter_mode = match self.filter_mode {
186            FilterMode::All => FilterMode::Running,
187            FilterMode::Running => FilterMode::Completed,
188            FilterMode::Completed => FilterMode::Failed,
189            FilterMode::Failed => FilterMode::Blocked,
190            FilterMode::Blocked => FilterMode::All,
191        };
192        self.selected = 0;
193    }
194
195    /// Toggle help display
196    fn toggle_help(&mut self) {
197        self.show_help = !self.show_help;
198    }
199
200    /// Toggle view mode
201    fn toggle_view_mode(&mut self) {
202        self.view_mode = match self.view_mode {
203            ViewMode::TaskList => ViewMode::DependencyGraph,
204            ViewMode::DependencyGraph => ViewMode::TaskList,
205        };
206        self.selected = 0;
207    }
208
209    /// Activate search mode
210    fn activate_search(&mut self) {
211        self.search_active = true;
212    }
213
214    /// Deactivate search mode
215    fn deactivate_search(&mut self) {
216        self.search_active = false;
217    }
218
219    /// Clear search query
220    fn clear_search(&mut self) {
221        self.search_query.clear();
222        self.selected = 0;
223    }
224
225    /// Add character to search query
226    fn add_to_search(&mut self, c: char) {
227        self.search_query.push(c);
228        self.selected = 0;
229    }
230
231    /// Remove last character from search query
232    fn backspace_search(&mut self) {
233        self.search_query.pop();
234        self.selected = 0;
235    }
236
237    /// Export data to file
238    fn export_data(&mut self) -> io::Result<()> {
239        use crate::export::{ChromeTraceExporter, CsvExporter, JsonExporter};
240        use std::fs;
241
242        // Create export directory
243        let export_dir = "tui_exports";
244        fs::create_dir_all(export_dir)?;
245
246        let timestamp = std::time::SystemTime::now()
247            .duration_since(std::time::UNIX_EPOCH)
248            .unwrap()
249            .as_secs();
250
251        // Export to multiple formats
252        JsonExporter::export_to_file(
253            &self.inspector,
254            format!("{export_dir}/export_{timestamp}.json"),
255        )?;
256
257        CsvExporter::export_tasks_to_file(
258            &self.inspector,
259            format!("{export_dir}/tasks_{timestamp}.csv"),
260        )?;
261
262        CsvExporter::export_events_to_file(
263            &self.inspector,
264            format!("{export_dir}/events_{timestamp}.csv"),
265        )?;
266
267        ChromeTraceExporter::export_to_file(
268            &self.inspector,
269            format!("{export_dir}/trace_{timestamp}.json"),
270        )?;
271
272        Ok(())
273    }
274}
275
276/// Run the TUI application
277pub fn run_tui(inspector: Inspector) -> io::Result<()> {
278    // Setup terminal
279    enable_raw_mode()?;
280    let mut stdout = io::stdout();
281    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
282    let backend = CrosstermBackend::new(stdout);
283    let mut terminal = Terminal::new(backend)?;
284
285    // Create app
286    let mut app = TuiApp::new(inspector);
287
288    // Run main loop
289    let result = run_app(&mut terminal, &mut app);
290
291    // Restore terminal
292    disable_raw_mode()?;
293    execute!(
294        terminal.backend_mut(),
295        LeaveAlternateScreen,
296        DisableMouseCapture
297    )?;
298    terminal.show_cursor()?;
299
300    result
301}
302
303/// Main application loop
304fn run_app<B: ratatui::backend::Backend>(
305    terminal: &mut Terminal<B>,
306    app: &mut TuiApp,
307) -> io::Result<()> {
308    loop {
309        terminal.draw(|f| ui(f, app))?;
310
311        // Handle input with timeout
312        if event::poll(app.update_interval)? {
313            match event::read()? {
314                Event::Key(key) => {
315                    // Handle search mode separately
316                    if app.search_active {
317                        match key.code {
318                            KeyCode::Esc => {
319                                app.deactivate_search();
320                                app.clear_search();
321                            }
322                            KeyCode::Enter => app.deactivate_search(),
323                            KeyCode::Backspace => app.backspace_search(),
324                            KeyCode::Char(c) => app.add_to_search(c),
325                            _ => {}
326                        }
327                    } else {
328                        match key.code {
329                            KeyCode::Char('q') => return Ok(()),
330                            KeyCode::Char('h' | '?') => app.toggle_help(),
331                            KeyCode::Char('s') => app.next_sort_mode(),
332                            KeyCode::Char('f') => app.next_filter_mode(),
333                            KeyCode::Char('v') => app.toggle_view_mode(),
334                            KeyCode::Char('/') => app.activate_search(),
335                            KeyCode::Char('c') => app.clear_search(),
336                            KeyCode::Char('e') => {
337                                if let Err(e) = app.export_data() {
338                                    // Store error for display (we'll add a status bar later)
339                                    eprintln!("Export failed: {e}");
340                                }
341                            }
342                            KeyCode::Up => app.select_previous(),
343                            KeyCode::Down => {
344                                let tasks = app.get_tasks();
345                                app.select_next(tasks.len());
346                            }
347                            KeyCode::Char('r') => app.selected = 0, // Reset selection
348                            _ => {}
349                        }
350                    }
351                }
352                Event::Mouse(mouse) => {
353                    handle_mouse_event(app, mouse);
354                }
355                _ => {}
356            }
357        }
358
359        app.last_update = Instant::now();
360    }
361}
362
363/// Handle mouse events
364fn handle_mouse_event(app: &mut TuiApp, mouse: MouseEvent) {
365    match mouse.kind {
366        MouseEventKind::ScrollDown => {
367            let tasks = app.get_tasks();
368            app.select_next(tasks.len());
369        }
370        MouseEventKind::ScrollUp => {
371            app.select_previous();
372        }
373        MouseEventKind::Down(_button) => {
374            // Click support: calculate which row was clicked
375            // This is a simplified version; precise calculation would need to track widget positions
376            // For now, we support scroll wheel which is most useful
377        }
378        _ => {}
379    }
380}
381
382/// Draw the UI
383fn ui(f: &mut Frame, app: &mut TuiApp) {
384    if app.show_help {
385        draw_help(f);
386        return;
387    }
388
389    // Create main layout
390    let mut constraints = vec![
391        Constraint::Length(3), // Header
392        Constraint::Length(7), // Stats
393    ];
394
395    // Add search bar if active or has query
396    if app.search_active || !app.search_query.is_empty() {
397        constraints.push(Constraint::Length(3)); // Search bar
398    }
399
400    constraints.push(Constraint::Min(10)); // Main content
401    constraints.push(Constraint::Length(3)); // Footer
402
403    let chunks = Layout::default()
404        .direction(Direction::Vertical)
405        .constraints(constraints)
406        .split(f.area());
407
408    let mut idx = 0;
409    draw_header(f, chunks[idx], app);
410    idx += 1;
411
412    draw_stats(f, chunks[idx], app);
413    idx += 1;
414
415    if app.search_active || !app.search_query.is_empty() {
416        draw_search_bar(f, chunks[idx], app);
417        idx += 1;
418    }
419
420    // Render based on view mode
421    match app.view_mode {
422        ViewMode::TaskList => draw_tasks(f, chunks[idx], app),
423        ViewMode::DependencyGraph => draw_dependency_graph(f, chunks[idx], app),
424    }
425    idx += 1;
426
427    draw_footer(f, chunks[idx], app);
428}
429
430/// Draw header
431fn draw_header(f: &mut Frame, area: Rect, _app: &TuiApp) {
432    let title = vec![Line::from(vec![
433        Span::styled(
434            "async-inspect",
435            Style::default()
436                .fg(Color::Cyan)
437                .add_modifier(Modifier::BOLD),
438        ),
439        Span::raw(" - Real-time Async Task Monitor"),
440    ])];
441
442    let header = Paragraph::new(title)
443        .block(Block::default().borders(Borders::ALL).title("Dashboard"))
444        .style(Style::default());
445
446    f.render_widget(header, area);
447}
448
449/// Draw statistics panel
450fn draw_stats(f: &mut Frame, area: Rect, app: &TuiApp) {
451    let stats = app.inspector.stats();
452
453    let stats_text = vec![
454        Line::from(vec![
455            Span::styled("Total: ", Style::default().fg(Color::Gray)),
456            Span::styled(
457                format!("{}", stats.total_tasks),
458                Style::default()
459                    .fg(Color::White)
460                    .add_modifier(Modifier::BOLD),
461            ),
462            Span::raw("  "),
463            Span::styled("Running: ", Style::default().fg(Color::Blue)),
464            Span::styled(
465                format!("{}", stats.running_tasks),
466                Style::default()
467                    .fg(Color::Blue)
468                    .add_modifier(Modifier::BOLD),
469            ),
470            Span::raw("  "),
471            Span::styled("Blocked: ", Style::default().fg(Color::Yellow)),
472            Span::styled(
473                format!("{}", stats.blocked_tasks),
474                Style::default()
475                    .fg(Color::Yellow)
476                    .add_modifier(Modifier::BOLD),
477            ),
478        ]),
479        Line::from(vec![
480            Span::styled("Completed: ", Style::default().fg(Color::Green)),
481            Span::styled(
482                format!("{}", stats.completed_tasks),
483                Style::default()
484                    .fg(Color::Green)
485                    .add_modifier(Modifier::BOLD),
486            ),
487            Span::raw("  "),
488            Span::styled("Failed: ", Style::default().fg(Color::Red)),
489            Span::styled(
490                format!("{}", stats.failed_tasks),
491                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
492            ),
493            Span::raw("  "),
494            Span::styled("Events: ", Style::default().fg(Color::Gray)),
495            Span::styled(
496                format!("{}", stats.total_events),
497                Style::default().fg(Color::White),
498            ),
499        ]),
500        Line::from(vec![
501            Span::styled("Duration: ", Style::default().fg(Color::Gray)),
502            Span::styled(
503                format!("{:.2}s", stats.timeline_duration.as_secs_f64()),
504                Style::default().fg(Color::Cyan),
505            ),
506        ]),
507    ];
508
509    let stats_widget = Paragraph::new(stats_text)
510        .block(Block::default().borders(Borders::ALL).title("Statistics"))
511        .style(Style::default());
512
513    f.render_widget(stats_widget, area);
514}
515
516/// Draw task list
517fn draw_tasks(f: &mut Frame, area: Rect, app: &TuiApp) {
518    let tasks = app.get_tasks();
519
520    let rows: Vec<Row> = tasks
521        .iter()
522        .enumerate()
523        .map(|(i, task)| {
524            let state_color = match task.state {
525                TaskState::Pending => Color::Gray,
526                TaskState::Running => Color::Blue,
527                TaskState::Blocked { .. } => Color::Yellow,
528                TaskState::Completed => Color::Green,
529                TaskState::Failed => Color::Red,
530            };
531
532            let state_str = match &task.state {
533                TaskState::Pending => "PENDING",
534                TaskState::Running => "RUNNING",
535                TaskState::Blocked { .. } => "BLOCKED",
536                TaskState::Completed => "DONE",
537                TaskState::Failed => "FAILED",
538            };
539
540            let style = if i == app.selected {
541                Style::default().bg(Color::DarkGray).fg(Color::White)
542            } else {
543                Style::default()
544            };
545
546            Row::new(vec![
547                format!("#{}", task.id.as_u64()),
548                format!("{:.20}", task.name),
549                state_str.to_string(),
550                format!("{:.2}ms", task.age().as_secs_f64() * 1000.0),
551                format!("{}", task.poll_count),
552                format!("{:.2}ms", task.total_run_time.as_secs_f64() * 1000.0),
553            ])
554            .style(style)
555            .fg(state_color)
556        })
557        .collect();
558
559    let title = format!(
560        "Tasks (Sort: {:?} | Filter: {:?}) - {} shown",
561        app.sort_mode,
562        app.filter_mode,
563        tasks.len()
564    );
565
566    let table = Table::new(
567        rows,
568        [
569            Constraint::Length(8),  // ID
570            Constraint::Min(20),    // Name
571            Constraint::Length(10), // State
572            Constraint::Length(12), // Duration
573            Constraint::Length(8),  // Polls
574            Constraint::Length(12), // Run Time
575        ],
576    )
577    .header(
578        Row::new(vec!["ID", "Name", "State", "Duration", "Polls", "Run Time"])
579            .style(
580                Style::default()
581                    .fg(Color::Yellow)
582                    .add_modifier(Modifier::BOLD),
583            )
584            .bottom_margin(1),
585    )
586    .block(Block::default().borders(Borders::ALL).title(title))
587    .row_highlight_style(Style::default().bg(Color::DarkGray));
588
589    f.render_widget(table, area);
590}
591
592/// Draw search bar
593fn draw_search_bar(f: &mut Frame, area: Rect, app: &TuiApp) {
594    let search_text = if app.search_active {
595        format!("Search: {}█", app.search_query)
596    } else {
597        format!("Search: {} (Press / to edit, c to clear)", app.search_query)
598    };
599
600    let search = Paragraph::new(search_text)
601        .block(
602            Block::default()
603                .borders(Borders::ALL)
604                .title("Search")
605                .border_style(if app.search_active {
606                    Style::default().fg(Color::Green)
607                } else {
608                    Style::default()
609                }),
610        )
611        .style(Style::default().fg(if app.search_active {
612            Color::Green
613        } else {
614            Color::White
615        }));
616
617    f.render_widget(search, area);
618}
619
620/// Draw dependency graph view
621fn draw_dependency_graph(f: &mut Frame, area: Rect, app: &TuiApp) {
622    let tasks = app.get_tasks();
623
624    // Build parent-child relationships
625    let mut tree_lines = Vec::new();
626    let mut root_tasks: Vec<_> = tasks.iter().filter(|t| t.parent.is_none()).collect();
627    root_tasks.sort_by_key(|t| t.id.as_u64());
628
629    for root in &root_tasks {
630        build_tree_lines(&tasks, root, 0, &mut tree_lines);
631    }
632
633    let rows: Vec<Row> = tree_lines
634        .iter()
635        .enumerate()
636        .map(|(i, (indent, task))| {
637            let state_color = match task.state {
638                TaskState::Pending => Color::Gray,
639                TaskState::Running => Color::Blue,
640                TaskState::Blocked { .. } => Color::Yellow,
641                TaskState::Completed => Color::Green,
642                TaskState::Failed => Color::Red,
643            };
644
645            let state_str = match &task.state {
646                TaskState::Pending => "PENDING",
647                TaskState::Running => "RUNNING",
648                TaskState::Blocked { .. } => "BLOCKED",
649                TaskState::Completed => "DONE",
650                TaskState::Failed => "FAILED",
651            };
652
653            let tree_prefix = "  ".repeat(*indent);
654            let tree_symbol = if *indent > 0 { "└─ " } else { "" };
655
656            let style = if i == app.selected {
657                Style::default().bg(Color::DarkGray).fg(Color::White)
658            } else {
659                Style::default()
660            };
661
662            Row::new(vec![
663                format!("#{}", task.id.as_u64()),
664                format!("{}{}{}", tree_prefix, tree_symbol, task.name),
665                state_str.to_string(),
666                format!("{:.2}ms", task.age().as_secs_f64() * 1000.0),
667            ])
668            .style(style)
669            .fg(state_color)
670        })
671        .collect();
672
673    let title = format!("Dependency Graph - {} tasks", tasks.len());
674
675    let table = Table::new(
676        rows,
677        [
678            Constraint::Length(8),  // ID
679            Constraint::Min(30),    // Name (with tree)
680            Constraint::Length(10), // State
681            Constraint::Length(12), // Duration
682        ],
683    )
684    .header(
685        Row::new(vec!["ID", "Task Tree", "State", "Duration"])
686            .style(
687                Style::default()
688                    .fg(Color::Yellow)
689                    .add_modifier(Modifier::BOLD),
690            )
691            .bottom_margin(1),
692    )
693    .block(Block::default().borders(Borders::ALL).title(title))
694    .row_highlight_style(Style::default().bg(Color::DarkGray));
695
696    f.render_widget(table, area);
697}
698
699/// Helper to build tree lines recursively
700fn build_tree_lines<'a>(
701    all_tasks: &'a [TaskInfo],
702    task: &'a TaskInfo,
703    indent: usize,
704    lines: &mut Vec<(usize, &'a TaskInfo)>,
705) {
706    lines.push((indent, task));
707
708    // Find children
709    let mut children: Vec<_> = all_tasks
710        .iter()
711        .filter(|t| t.parent.is_some_and(|p| p == task.id))
712        .collect();
713    children.sort_by_key(|t| t.id.as_u64());
714
715    for child in children {
716        build_tree_lines(all_tasks, child, indent + 1, lines);
717    }
718}
719
720/// Draw footer with help hint
721fn draw_footer(f: &mut Frame, area: Rect, app: &TuiApp) {
722    let view_mode_str = match app.view_mode {
723        ViewMode::TaskList => "List",
724        ViewMode::DependencyGraph => "Graph",
725    };
726
727    let help_text = vec![Line::from(vec![
728        Span::styled("[q]", Style::default().fg(Color::Yellow)),
729        Span::raw(" Quit  "),
730        Span::styled("[v]", Style::default().fg(Color::Yellow)),
731        Span::raw(format!(" View:{view_mode_str}  ")),
732        Span::styled("[/]", Style::default().fg(Color::Yellow)),
733        Span::raw(" Search  "),
734        Span::styled("[e]", Style::default().fg(Color::Yellow)),
735        Span::raw(" Export  "),
736        Span::styled("[h/?]", Style::default().fg(Color::Yellow)),
737        Span::raw(" Help"),
738    ])];
739
740    let footer = Paragraph::new(help_text)
741        .block(Block::default().borders(Borders::ALL))
742        .style(Style::default());
743
744    f.render_widget(footer, area);
745}
746
747/// Draw help screen
748fn draw_help(f: &mut Frame) {
749    let help_text = vec![
750        Line::from(""),
751        Line::from(Span::styled(
752            "  Keyboard Shortcuts",
753            Style::default()
754                .fg(Color::Cyan)
755                .add_modifier(Modifier::BOLD),
756        )),
757        Line::from(""),
758        Line::from(Span::styled(
759            "  Navigation & Control:",
760            Style::default()
761                .fg(Color::Green)
762                .add_modifier(Modifier::BOLD),
763        )),
764        Line::from(vec![
765            Span::styled("  q", Style::default().fg(Color::Yellow)),
766            Span::raw("           Quit the application"),
767        ]),
768        Line::from(vec![
769            Span::styled("  h or ?", Style::default().fg(Color::Yellow)),
770            Span::raw("      Toggle this help screen"),
771        ]),
772        Line::from(vec![
773            Span::styled("  ↑/↓", Style::default().fg(Color::Yellow)),
774            Span::raw("         Navigate task list (or use mouse scroll)"),
775        ]),
776        Line::from(vec![
777            Span::styled("  r", Style::default().fg(Color::Yellow)),
778            Span::raw("           Reset selection to top"),
779        ]),
780        Line::from(vec![
781            Span::styled("  Mouse", Style::default().fg(Color::Yellow)),
782            Span::raw("        Scroll wheel to navigate tasks"),
783        ]),
784        Line::from(""),
785        Line::from(Span::styled(
786            "  View & Display:",
787            Style::default()
788                .fg(Color::Green)
789                .add_modifier(Modifier::BOLD),
790        )),
791        Line::from(vec![
792            Span::styled("  v", Style::default().fg(Color::Yellow)),
793            Span::raw("           Toggle view mode (List ↔ Dependency Graph)"),
794        ]),
795        Line::from(vec![
796            Span::styled("  s", Style::default().fg(Color::Yellow)),
797            Span::raw("           Cycle sort mode (ID → Name → Duration → State → Polls)"),
798        ]),
799        Line::from(vec![
800            Span::styled("  f", Style::default().fg(Color::Yellow)),
801            Span::raw(
802                "           Cycle filter mode (All → Running → Completed → Failed → Blocked)",
803            ),
804        ]),
805        Line::from(""),
806        Line::from(Span::styled(
807            "  Search & Export:",
808            Style::default()
809                .fg(Color::Green)
810                .add_modifier(Modifier::BOLD),
811        )),
812        Line::from(vec![
813            Span::styled("  /", Style::default().fg(Color::Yellow)),
814            Span::raw("           Activate search mode (type to filter tasks)"),
815        ]),
816        Line::from(vec![
817            Span::styled("  c", Style::default().fg(Color::Yellow)),
818            Span::raw("           Clear search query"),
819        ]),
820        Line::from(vec![
821            Span::styled("  ESC", Style::default().fg(Color::Yellow)),
822            Span::raw("         Exit search mode (while searching)"),
823        ]),
824        Line::from(vec![
825            Span::styled("  e", Style::default().fg(Color::Yellow)),
826            Span::raw("           Export data (JSON, CSV, Chrome Trace to tui_exports/)"),
827        ]),
828        Line::from(""),
829        Line::from(Span::styled(
830            "  View Modes:",
831            Style::default()
832                .fg(Color::Magenta)
833                .add_modifier(Modifier::BOLD),
834        )),
835        Line::from("    • Task List: Standard sortable task list"),
836        Line::from("    • Dependency Graph: Hierarchical tree showing parent-child relationships"),
837        Line::from(""),
838        Line::from(Span::styled(
839            "  Press h or ? to return",
840            Style::default().fg(Color::Yellow),
841        )),
842    ];
843
844    let help = Paragraph::new(help_text)
845        .block(
846            Block::default()
847                .borders(Borders::ALL)
848                .title("Help")
849                .border_style(Style::default().fg(Color::Cyan)),
850        )
851        .style(Style::default());
852
853    // Center the help box
854    let area = centered_rect(60, 80, f.area());
855    f.render_widget(help, area);
856}
857
858/// Helper to create a centered rectangle
859fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
860    let popup_layout = Layout::default()
861        .direction(Direction::Vertical)
862        .constraints([
863            Constraint::Percentage((100 - percent_y) / 2),
864            Constraint::Percentage(percent_y),
865            Constraint::Percentage((100 - percent_y) / 2),
866        ])
867        .split(r);
868
869    Layout::default()
870        .direction(Direction::Horizontal)
871        .constraints([
872            Constraint::Percentage((100 - percent_x) / 2),
873            Constraint::Percentage(percent_x),
874            Constraint::Percentage((100 - percent_x) / 2),
875        ])
876        .split(popup_layout[1])[1]
877}