scud/commands/spawn/tui/
ui.rs

1//! UI rendering for TUI monitor
2//!
3//! Three-panel design:
4//! - Top: Waves/Tasks panel showing tasks by execution wave
5//! - Middle: Agents panel showing running agents
6//! - Bottom: Live terminal output from selected agent
7//!
8//! Minimalist Zen aesthetic with calm colors and clean typography.
9
10use ratatui::{
11    layout::{Alignment, Constraint, Layout, Rect},
12    style::{Color, Modifier, Style, Stylize},
13    text::{Line, Span},
14    widgets::{
15        Block, BorderType, Borders, Clear, List, ListItem, Padding, Paragraph, Scrollbar,
16        ScrollbarOrientation, ScrollbarState,
17    },
18    Frame,
19};
20
21use crate::commands::spawn::monitor::AgentStatus;
22
23use super::app::{App, FocusedPanel, ViewMode, WaveTaskState};
24
25// ─────────────────────────────────────────────────────────────
26// Color Palette: Zen minimalist
27// ─────────────────────────────────────────────────────────────
28
29const BG_PRIMARY: Color = Color::Rgb(15, 23, 42); // Deep slate
30const BG_SECONDARY: Color = Color::Rgb(30, 41, 59); // Elevated surface
31const BG_TERMINAL: Color = Color::Rgb(22, 22, 22); // Terminal black
32const TEXT_PRIMARY: Color = Color::Rgb(226, 232, 240); // Soft white
33const TEXT_MUTED: Color = Color::Rgb(100, 116, 139); // Subdued
34const TEXT_TERMINAL: Color = Color::Rgb(200, 200, 200); // Terminal text
35const BORDER_DEFAULT: Color = Color::Rgb(51, 65, 85); // Subtle border
36const BORDER_ACTIVE: Color = Color::Rgb(96, 165, 250); // Active border
37const ACCENT: Color = Color::Rgb(96, 165, 250); // Calm blue
38const STATUS_STARTING: Color = Color::Rgb(148, 163, 184); // Gray
39const STATUS_RUNNING: Color = Color::Rgb(34, 197, 94); // Green
40const STATUS_COMPLETED: Color = Color::Rgb(96, 165, 250); // Blue
41const STATUS_FAILED: Color = Color::Rgb(248, 113, 113); // Soft red
42
43/// Main render function
44pub fn render(frame: &mut Frame, app: &mut App) {
45    let area = frame.area();
46
47    // Fill background
48    frame.render_widget(
49        Block::default().style(Style::default().bg(BG_PRIMARY)),
50        area,
51    );
52
53    match app.view_mode {
54        ViewMode::Split => render_split_view(frame, area, app),
55        ViewMode::Fullscreen => render_fullscreen_view(frame, area, app),
56        ViewMode::Input => render_input_view(frame, area, app),
57    }
58
59    // Help overlay (on top of everything)
60    if app.show_help {
61        render_help_overlay(frame, area, app);
62    }
63}
64
65fn render_split_view(frame: &mut Frame, area: Rect, app: &mut App) {
66    // Main layout: header, three panels, footer
67    let [header_area, content_area, footer_area] = Layout::vertical([
68        Constraint::Length(3),
69        Constraint::Fill(1),
70        Constraint::Length(2),
71    ])
72    .areas(area);
73
74    render_header(frame, header_area, app);
75    render_three_panel_content(frame, content_area, app);
76    render_footer(frame, footer_area, app);
77}
78
79fn render_fullscreen_view(frame: &mut Frame, area: Rect, app: &App) {
80    // Fullscreen: small header + terminal + small footer
81    let [header_area, terminal_area, footer_area] = Layout::vertical([
82        Constraint::Length(2),
83        Constraint::Fill(1),
84        Constraint::Length(2),
85    ])
86    .areas(area);
87
88    render_fullscreen_header(frame, header_area, app);
89    render_terminal_output(frame, terminal_area, app, true);
90    render_fullscreen_footer(frame, footer_area);
91}
92
93fn render_input_view(frame: &mut Frame, area: Rect, app: &App) {
94    // Input view: header + terminal + input bar + footer
95    let [header_area, terminal_area, input_area, footer_area] = Layout::vertical([
96        Constraint::Length(2),
97        Constraint::Fill(1),
98        Constraint::Length(3),
99        Constraint::Length(2),
100    ])
101    .areas(area);
102
103    render_fullscreen_header(frame, header_area, app);
104    render_terminal_output(frame, terminal_area, app, true);
105    render_input_bar(frame, input_area, app);
106    render_input_footer(frame, footer_area);
107}
108
109fn render_input_bar(frame: &mut Frame, area: Rect, app: &App) {
110    let input_text = format!("▸ {}", app.input_buffer);
111
112    let input = Paragraph::new(Line::from(vec![
113        Span::styled(&input_text, Style::default().fg(TEXT_PRIMARY)),
114        Span::styled("█", Style::default().fg(ACCENT)), // Cursor
115    ]))
116    .block(
117        Block::default()
118            .borders(Borders::ALL)
119            .border_type(BorderType::Rounded)
120            .border_style(Style::default().fg(ACCENT))
121            .title(Line::from(" Send to Agent ").fg(ACCENT))
122            .style(Style::default().bg(BG_SECONDARY))
123            .padding(Padding::horizontal(1)),
124    );
125
126    frame.render_widget(input, area);
127}
128
129fn render_input_footer(frame: &mut Frame, area: Rect) {
130    let help_text = " Enter Send  ·  Esc Cancel  ·  Type your message... ";
131
132    let footer = Paragraph::new(Line::from(vec![Span::styled(
133        help_text,
134        Style::default().fg(TEXT_MUTED),
135    )]))
136    .alignment(Alignment::Center)
137    .style(Style::default().bg(BG_PRIMARY));
138
139    frame.render_widget(footer, area);
140}
141
142fn render_header(frame: &mut Frame, area: Rect, app: &App) {
143    let (starting, running, completed, failed) = app.status_counts();
144
145    // Ralph mode indicator
146    let ralph_indicator = if app.ralph_mode {
147        vec![
148            Span::styled("  🔄 ", Style::default()),
149            Span::styled(
150                "RALPH ",
151                Style::default().fg(Color::Rgb(255, 165, 0)).bold(),
152            ),
153        ]
154    } else {
155        vec![]
156    };
157
158    // Status line with legend labels
159    let mut spans = vec![
160        Span::styled(" ", Style::default()),
161        Span::styled(&app.session_name, Style::default().fg(ACCENT).bold()),
162    ];
163    spans.extend(ralph_indicator);
164    spans.extend(vec![
165        Span::styled("    ", Style::default()),
166        // Gray = Starting/Waiting
167        Span::styled("◉ ", Style::default().fg(STATUS_STARTING)),
168        Span::styled("Starting ", Style::default().fg(TEXT_MUTED).dim()),
169        Span::styled(
170            format!("{}  ", starting),
171            Style::default().fg(STATUS_STARTING),
172        ),
173        // Green = Running
174        Span::styled("◉ ", Style::default().fg(STATUS_RUNNING)),
175        Span::styled("Running ", Style::default().fg(TEXT_MUTED).dim()),
176        Span::styled(
177            format!("{}  ", running),
178            Style::default().fg(STATUS_RUNNING),
179        ),
180        // Blue = Completed
181        Span::styled("◉ ", Style::default().fg(STATUS_COMPLETED)),
182        Span::styled("Done ", Style::default().fg(TEXT_MUTED).dim()),
183        Span::styled(
184            format!("{}  ", completed),
185            Style::default().fg(STATUS_COMPLETED),
186        ),
187        // Red = Failed
188        Span::styled("◉ ", Style::default().fg(STATUS_FAILED)),
189        Span::styled("Failed ", Style::default().fg(TEXT_MUTED).dim()),
190        Span::styled(format!("{}", failed), Style::default().fg(STATUS_FAILED)),
191    ]);
192    let status_line = Line::from(spans);
193
194    let header = Paragraph::new(status_line).block(
195        Block::default()
196            .borders(Borders::BOTTOM)
197            .border_style(Style::default().fg(BORDER_DEFAULT))
198            .style(Style::default().bg(BG_SECONDARY))
199            .padding(Padding::horizontal(1)),
200    );
201
202    frame.render_widget(header, area);
203}
204
205fn render_fullscreen_header(frame: &mut Frame, area: Rect, app: &App) {
206    let agent_name = app
207        .selected_agent()
208        .map(|a| format!("{}: {}", a.task_id, a.task_title))
209        .unwrap_or_else(|| "No agent".to_string());
210
211    let title = Line::from(vec![
212        Span::styled(" ", Style::default()),
213        Span::styled(&agent_name, Style::default().fg(ACCENT).bold()),
214    ]);
215
216    let header = Paragraph::new(title).block(
217        Block::default()
218            .borders(Borders::BOTTOM)
219            .border_style(Style::default().fg(BORDER_ACTIVE))
220            .style(Style::default().bg(BG_SECONDARY)),
221    );
222
223    frame.render_widget(header, area);
224}
225
226fn render_three_panel_content(frame: &mut Frame, area: Rect, app: &mut App) {
227    // Three vertical panels: waves (top), agents (middle), output (bottom)
228    // Dynamic sizing based on content
229    let has_agents = !app.agents().is_empty();
230    let has_waves = !app.waves.is_empty();
231
232    let constraints = if has_waves && has_agents {
233        vec![
234            Constraint::Percentage(35), // Waves
235            Constraint::Percentage(25), // Agents
236            Constraint::Percentage(40), // Output
237        ]
238    } else if has_waves {
239        vec![
240            Constraint::Percentage(50), // Waves
241            Constraint::Length(3),      // Agents (minimal)
242            Constraint::Percentage(50), // Output
243        ]
244    } else if has_agents {
245        vec![
246            Constraint::Length(3),      // Waves (minimal)
247            Constraint::Percentage(40), // Agents
248            Constraint::Percentage(60), // Output
249        ]
250    } else {
251        vec![
252            Constraint::Length(3),
253            Constraint::Length(3),
254            Constraint::Fill(1),
255        ]
256    };
257
258    let [waves_area, agents_area, output_area] = Layout::vertical(constraints).areas(area);
259
260    render_waves_panel(frame, waves_area, app);
261    render_agents_panel(frame, agents_area, app);
262    render_terminal_output(frame, output_area, app, false);
263}
264
265fn render_waves_panel(frame: &mut Frame, area: Rect, app: &App) {
266    let is_focused = app.focused_panel == FocusedPanel::Waves;
267    let border_color = if is_focused {
268        BORDER_ACTIVE
269    } else {
270        BORDER_DEFAULT
271    };
272    let title_color = if is_focused { ACCENT } else { TEXT_MUTED };
273
274    let ready_count = app.ready_task_count();
275    let selected_count = app.selected_task_count();
276    let title = if selected_count > 0 {
277        format!(
278            " Waves & Tasks ({} selected / {} ready) ",
279            selected_count, ready_count
280        )
281    } else {
282        format!(" Waves & Tasks ({} ready) ", ready_count)
283    };
284
285    let block = Block::default()
286        .borders(Borders::ALL)
287        .border_type(BorderType::Rounded)
288        .border_style(Style::default().fg(border_color))
289        .title(Line::from(title).fg(title_color))
290        .style(Style::default().bg(BG_SECONDARY))
291        .padding(Padding::new(1, 1, 0, 0));
292
293    if app.waves.is_empty() {
294        let empty_msg = Paragraph::new("No actionable tasks")
295            .style(Style::default().fg(TEXT_MUTED))
296            .block(block);
297        frame.render_widget(empty_msg, area);
298        return;
299    }
300
301    // Build list items from waves
302    let mut all_items: Vec<ListItem> = Vec::new();
303    let mut task_index = 0;
304
305    for wave in &app.waves {
306        // Wave header
307        let ready_in_wave = wave
308            .tasks
309            .iter()
310            .filter(|t| t.state == WaveTaskState::Ready)
311            .count();
312        let wave_header = Line::from(vec![
313            Span::styled(
314                format!("Wave {} ", wave.number),
315                Style::default().fg(ACCENT).bold(),
316            ),
317            Span::styled(
318                format!("({} tasks, {} ready)", wave.tasks.len(), ready_in_wave),
319                Style::default().fg(TEXT_MUTED),
320            ),
321        ]);
322        all_items.push(ListItem::new(wave_header));
323
324        // Tasks in wave
325        for task in &wave.tasks {
326            let is_selected_in_list = task_index == app.wave_task_index && is_focused;
327            let is_selected_for_spawn = app.selected_tasks.contains(&task.id);
328
329            let state_icon = match task.state {
330                WaveTaskState::Ready => ("○", STATUS_COMPLETED), // Blue circle = ready
331                WaveTaskState::Running => ("●", STATUS_RUNNING), // Green filled = running
332                WaveTaskState::Done => ("✓", STATUS_COMPLETED),  // Blue check = done
333                WaveTaskState::Blocked => ("◌", TEXT_MUTED),     // Hollow = blocked
334                WaveTaskState::InProgress => ("◐", STATUS_RUNNING), // Half = in progress
335            };
336
337            let checkbox = if is_selected_for_spawn {
338                "[x]"
339            } else if task.state == WaveTaskState::Ready {
340                "[ ]"
341            } else {
342                "   "
343            };
344
345            // Truncate title
346            let max_len = 40;
347            let title_display = if task.title.len() > max_len {
348                format!("{}…", &task.title[..max_len - 1])
349            } else {
350                task.title.clone()
351            };
352
353            let complexity = if task.complexity > 0 {
354                format!(" [{}]", task.complexity)
355            } else {
356                String::new()
357            };
358
359            let line = Line::from(vec![
360                Span::styled(
361                    if is_selected_in_list { "▸ " } else { "  " },
362                    Style::default().fg(ACCENT),
363                ),
364                Span::styled(
365                    format!("{} ", checkbox),
366                    Style::default().fg(if is_selected_for_spawn {
367                        ACCENT
368                    } else {
369                        TEXT_MUTED
370                    }),
371                ),
372                Span::styled(
373                    format!("{} ", state_icon.0),
374                    Style::default().fg(state_icon.1),
375                ),
376                Span::styled(format!("{} ", task.id), Style::default().fg(TEXT_MUTED)),
377                Span::styled(
378                    title_display,
379                    Style::default()
380                        .fg(if is_selected_in_list {
381                            ACCENT
382                        } else {
383                            TEXT_PRIMARY
384                        })
385                        .add_modifier(if is_selected_in_list {
386                            Modifier::BOLD
387                        } else {
388                            Modifier::empty()
389                        }),
390                ),
391                Span::styled(complexity, Style::default().fg(TEXT_MUTED)),
392            ]);
393
394            all_items.push(ListItem::new(line));
395            task_index += 1;
396        }
397    }
398
399    // Apply scroll offset - skip first N items
400    let visible_items: Vec<ListItem> = all_items.into_iter().skip(app.wave_scroll_offset).collect();
401
402    let list = List::new(visible_items).block(block);
403    frame.render_widget(list, area);
404}
405
406fn render_agents_panel(frame: &mut Frame, area: Rect, app: &mut App) {
407    let is_focused = app.focused_panel == FocusedPanel::Agents;
408    let border_color = if is_focused {
409        BORDER_ACTIVE
410    } else {
411        BORDER_DEFAULT
412    };
413    let title_color = if is_focused { ACCENT } else { TEXT_MUTED };
414
415    // Get counts first before borrowing agents slice
416    let total = app.agents().len();
417    let running = app
418        .agents()
419        .iter()
420        .filter(|a| a.status == AgentStatus::Running)
421        .count();
422    let selected = app.selected;
423
424    // Calculate visible height for scroll
425    let inner_height = area.height.saturating_sub(3) as usize; // borders + padding
426
427    // Auto-adjust scroll to keep selected visible
428    if selected < app.agents_scroll_offset {
429        app.agents_scroll_offset = selected;
430    } else if total > 0 && inner_height > 0 && selected >= app.agents_scroll_offset + inner_height {
431        app.agents_scroll_offset = selected.saturating_sub(inner_height - 1);
432    }
433
434    let scroll_offset = app.agents_scroll_offset;
435
436    // Show scroll indicator if there are more agents than visible
437    let title = if total > inner_height && inner_height > 0 {
438        let visible_end = (scroll_offset + inner_height).min(total);
439        format!(
440            " Agents ({} running / {} total) [{}-{}] ",
441            running,
442            total,
443            scroll_offset + 1,
444            visible_end
445        )
446    } else {
447        format!(" Agents ({} running / {} total) ", running, total)
448    };
449
450    // Now borrow agents for rendering
451    let agents = app.agents();
452
453    let block = Block::default()
454        .borders(Borders::ALL)
455        .border_type(BorderType::Rounded)
456        .border_style(Style::default().fg(border_color))
457        .title(Line::from(title).fg(title_color))
458        .style(Style::default().bg(BG_SECONDARY))
459        .padding(Padding::new(1, 1, 0, 0));
460
461    if agents.is_empty() {
462        let empty_msg = Paragraph::new("No agents spawned yet")
463            .style(Style::default().fg(TEXT_MUTED))
464            .block(block);
465        frame.render_widget(empty_msg, area);
466        return;
467    }
468
469    let items: Vec<ListItem> = agents
470        .iter()
471        .enumerate()
472        .skip(scroll_offset)
473        .take(inner_height.max(1))
474        .map(|(i, agent)| {
475            let is_selected = i == selected && is_focused;
476
477            let status_icon = match agent.status {
478                AgentStatus::Starting => ("◐", STATUS_STARTING),
479                AgentStatus::Running => ("●", STATUS_RUNNING),
480                AgentStatus::Completed => ("✓", STATUS_COMPLETED),
481                AgentStatus::Failed => ("✗", STATUS_FAILED),
482            };
483
484            // Truncate title
485            let max_len = 35;
486            let title = if agent.task_title.len() > max_len {
487                format!("{}…", &agent.task_title[..max_len - 1])
488            } else {
489                agent.task_title.clone()
490            };
491
492            let line = Line::from(vec![
493                Span::styled(
494                    if is_selected { "▸ " } else { "  " },
495                    Style::default().fg(ACCENT),
496                ),
497                Span::styled(
498                    format!("{} ", status_icon.0),
499                    Style::default().fg(status_icon.1),
500                ),
501                Span::styled(
502                    format!("{}: ", agent.task_id),
503                    Style::default().fg(TEXT_MUTED),
504                ),
505                Span::styled(
506                    title,
507                    Style::default()
508                        .fg(if is_selected { ACCENT } else { TEXT_PRIMARY })
509                        .add_modifier(if is_selected {
510                            Modifier::BOLD
511                        } else {
512                            Modifier::empty()
513                        }),
514                ),
515            ]);
516
517            ListItem::new(line)
518        })
519        .collect();
520
521    let list = List::new(items).block(block);
522    frame.render_widget(list, area);
523}
524
525fn render_terminal_output(frame: &mut Frame, area: Rect, app: &App, fullscreen: bool) {
526    let is_focused = app.focused_panel == FocusedPanel::Output || fullscreen;
527
528    let title = if fullscreen {
529        " Terminal (Esc to exit) ".to_string()
530    } else if let Some(agent) = app.selected_agent() {
531        format!(" Output: {} ", agent.task_id)
532    } else {
533        " Live Output ".to_string()
534    };
535
536    let border_color = if is_focused {
537        BORDER_ACTIVE
538    } else {
539        BORDER_DEFAULT
540    };
541    let title_color = if is_focused { ACCENT } else { TEXT_MUTED };
542
543    let block = Block::default()
544        .borders(Borders::ALL)
545        .border_type(BorderType::Rounded)
546        .border_style(Style::default().fg(border_color))
547        .title(Line::from(title).fg(title_color))
548        .style(Style::default().bg(BG_TERMINAL))
549        .padding(Padding::new(1, 0, 0, 0)); // Left padding only, scrollbar uses right side
550
551    let inner = block.inner(area);
552    frame.render_widget(block, area);
553
554    // Render output lines
555    let visible_height = inner.height as usize;
556    let output = &app.live_output;
557
558    // Calculate visible window based on scroll offset
559    // scroll_offset=0 means bottom (most recent), higher = scrolled up
560    let total_lines = output.len();
561    let end_idx = total_lines.saturating_sub(app.scroll_offset);
562    let start_idx = end_idx.saturating_sub(visible_height);
563
564    // Reserve 2 chars on right: 1 for scrollbar, 1 for spacing
565    let text_width = inner.width.saturating_sub(2);
566    let text_area = Rect::new(inner.x, inner.y, text_width, inner.height);
567
568    let visible_lines: Vec<Line> = output
569        .iter()
570        .skip(start_idx)
571        .take(visible_height)
572        .map(|line| {
573            Line::from(Span::styled(
574                line.as_str(),
575                Style::default().fg(TEXT_TERMINAL),
576            ))
577        })
578        .collect();
579
580    let paragraph = Paragraph::new(visible_lines);
581    frame.render_widget(paragraph, text_area);
582
583    // Scrollbar on the right side of inner area (before the border)
584    if total_lines > visible_height {
585        let scrollbar_area = Rect::new(
586            inner.x + inner.width.saturating_sub(1),
587            inner.y,
588            1,
589            inner.height,
590        );
591
592        let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
593            .begin_symbol(None)
594            .end_symbol(None)
595            .track_symbol(Some(" "))
596            .thumb_symbol("▐");
597
598        let mut scrollbar_state = ScrollbarState::new(total_lines).position(start_idx);
599
600        frame.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
601    }
602}
603
604fn render_footer(frame: &mut Frame, area: Rect, app: &App) {
605    // Context-sensitive help based on focused panel
606    let ralph_hint = if app.ralph_mode {
607        "R Ralph OFF"
608    } else {
609        "R Ralph"
610    };
611    let help_text = match app.focused_panel {
612        FocusedPanel::Waves => format!(" Tab Panel  ·  j/k Navigate  ·  Space Select  ·  a All  ·  s Spawn  ·  {}  ·  ? Help  ·  q Quit ", ralph_hint),
613        FocusedPanel::Agents => format!(" Tab Panel  ·  j/k Navigate  ·  Enter View  ·  i Input  ·  x Stop  ·  {}  ·  ? Help  ·  q Quit ", ralph_hint),
614        FocusedPanel::Output => format!(" Tab Panel  ·  ↑↓ Scroll  ·  G Bottom  ·  Enter Fullscreen  ·  {}  ·  ? Help  ·  q Quit ", ralph_hint),
615    };
616
617    let mut line = Line::from(vec![Span::styled(
618        help_text,
619        Style::default().fg(TEXT_MUTED),
620    )]);
621
622    // Show error if present
623    if let Some(ref error) = app.error {
624        line = Line::from(vec![
625            Span::styled(" ⚠ ", Style::default().fg(STATUS_FAILED)),
626            Span::styled(error.as_str(), Style::default().fg(STATUS_FAILED)),
627        ]);
628    }
629
630    let footer = Paragraph::new(line)
631        .alignment(Alignment::Center)
632        .style(Style::default().bg(BG_PRIMARY));
633
634    frame.render_widget(footer, area);
635}
636
637fn render_fullscreen_footer(frame: &mut Frame, area: Rect) {
638    let help_text = " ↑↓ Scroll  ·  j/k Switch  ·  G Bottom  ·  i Input  ·  Esc Back  ·  q Quit ";
639
640    let footer = Paragraph::new(Line::from(vec![Span::styled(
641        help_text,
642        Style::default().fg(TEXT_MUTED),
643    )]))
644    .alignment(Alignment::Center)
645    .style(Style::default().bg(BG_PRIMARY));
646
647    frame.render_widget(footer, area);
648}
649
650fn render_help_overlay(frame: &mut Frame, area: Rect, app: &App) {
651    let overlay_width = 55.min(area.width.saturating_sub(4));
652    let overlay_height = 22.min(area.height.saturating_sub(2)); // Leave margin
653    let x = (area.width.saturating_sub(overlay_width)) / 2;
654    let y = (area.height.saturating_sub(overlay_height)) / 2;
655    let overlay_area = Rect::new(x, y, overlay_width, overlay_height);
656
657    frame.render_widget(Clear, overlay_area);
658
659    let mode_hint = match app.view_mode {
660        ViewMode::Split => "Three-Panel",
661        ViewMode::Fullscreen => "Fullscreen",
662        ViewMode::Input => "Input Mode",
663    };
664
665    let panel_hint = match app.focused_panel {
666        FocusedPanel::Waves => "Waves",
667        FocusedPanel::Agents => "Agents",
668        FocusedPanel::Output => "Output",
669    };
670
671    let help_text = vec![
672        Line::from(vec![
673            Span::styled(" Tab ", Style::default().fg(ACCENT)),
674            Span::styled("Panel ", Style::default().fg(TEXT_PRIMARY)),
675            Span::styled(" j/k ", Style::default().fg(ACCENT)),
676            Span::styled("Navigate ", Style::default().fg(TEXT_PRIMARY)),
677            Span::styled(" r ", Style::default().fg(ACCENT)),
678            Span::styled("Refresh", Style::default().fg(TEXT_PRIMARY)),
679        ]),
680        Line::from(""),
681        Line::from(Span::styled(" Waves:", Style::default().fg(TEXT_MUTED))),
682        Line::from(vec![
683            Span::styled(" Space ", Style::default().fg(ACCENT)),
684            Span::styled("Select ", Style::default().fg(TEXT_PRIMARY)),
685            Span::styled(" a ", Style::default().fg(ACCENT)),
686            Span::styled("All ", Style::default().fg(TEXT_PRIMARY)),
687            Span::styled(" c ", Style::default().fg(ACCENT)),
688            Span::styled("Clear ", Style::default().fg(TEXT_PRIMARY)),
689            Span::styled(" s ", Style::default().fg(ACCENT)),
690            Span::styled("Spawn", Style::default().fg(TEXT_PRIMARY)),
691        ]),
692        Line::from(""),
693        Line::from(Span::styled(" Agents:", Style::default().fg(TEXT_MUTED))),
694        Line::from(vec![
695            Span::styled(" Enter ", Style::default().fg(ACCENT)),
696            Span::styled("View ", Style::default().fg(TEXT_PRIMARY)),
697            Span::styled(" i ", Style::default().fg(ACCENT)),
698            Span::styled("Input ", Style::default().fg(TEXT_PRIMARY)),
699            Span::styled(" x ", Style::default().fg(ACCENT)),
700            Span::styled("Stop", Style::default().fg(TEXT_PRIMARY)),
701        ]),
702        Line::from(""),
703        Line::from(Span::styled(" Output:", Style::default().fg(TEXT_MUTED))),
704        Line::from(vec![
705            Span::styled(" ↑/↓ ", Style::default().fg(ACCENT)),
706            Span::styled("Scroll ", Style::default().fg(TEXT_PRIMARY)),
707            Span::styled(" G ", Style::default().fg(ACCENT)),
708            Span::styled("Bottom ", Style::default().fg(TEXT_PRIMARY)),
709            Span::styled(" Enter ", Style::default().fg(ACCENT)),
710            Span::styled("Fullscreen", Style::default().fg(TEXT_PRIMARY)),
711        ]),
712        Line::from(""),
713        Line::from(vec![
714            Span::styled(" ? ", Style::default().fg(ACCENT)),
715            Span::styled("Help ", Style::default().fg(TEXT_PRIMARY)),
716            Span::styled(" q/Esc ", Style::default().fg(ACCENT)),
717            Span::styled("Quit", Style::default().fg(TEXT_PRIMARY)),
718        ]),
719        Line::from(""),
720        Line::from(vec![Span::styled(
721            format!(" Mode: {} | Panel: {}", mode_hint, panel_hint),
722            Style::default().fg(TEXT_MUTED),
723        )]),
724    ];
725
726    let help_block = Block::default()
727        .borders(Borders::ALL)
728        .border_type(BorderType::Rounded)
729        .border_style(Style::default().fg(ACCENT))
730        .title(Line::from(" Keybindings ").fg(ACCENT).bold())
731        .title_alignment(Alignment::Center)
732        .style(Style::default().bg(BG_SECONDARY));
733
734    let help_para = Paragraph::new(help_text).block(help_block);
735    frame.render_widget(help_para, overlay_area);
736}