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