1use 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
25const BG_PRIMARY: Color = Color::Rgb(15, 23, 42); const BG_SECONDARY: Color = Color::Rgb(30, 41, 59); const BG_TERMINAL: Color = Color::Rgb(22, 22, 22); const TEXT_PRIMARY: Color = Color::Rgb(226, 232, 240); const TEXT_MUTED: Color = Color::Rgb(100, 116, 139); const TEXT_TERMINAL: Color = Color::Rgb(200, 200, 200); const BORDER_DEFAULT: Color = Color::Rgb(51, 65, 85); const BORDER_ACTIVE: Color = Color::Rgb(96, 165, 250); const ACCENT: Color = Color::Rgb(96, 165, 250); const STATUS_STARTING: Color = Color::Rgb(148, 163, 184); const STATUS_RUNNING: Color = Color::Rgb(34, 197, 94); const STATUS_COMPLETED: Color = Color::Rgb(96, 165, 250); const STATUS_FAILED: Color = Color::Rgb(248, 113, 113); pub fn render(frame: &mut Frame, app: &mut App) {
45 let area = frame.area();
46
47 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 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 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 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 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)), ]))
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 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 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 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 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 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 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 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), Constraint::Percentage(25), Constraint::Percentage(40), ]
238 } else if has_waves {
239 vec![
240 Constraint::Percentage(50), Constraint::Length(3), Constraint::Percentage(50), ]
244 } else if has_agents {
245 vec![
246 Constraint::Length(3), Constraint::Percentage(40), Constraint::Percentage(60), ]
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 let mut all_items: Vec<ListItem> = Vec::new();
303 let mut task_index = 0;
304
305 for wave in &app.waves {
306 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 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), WaveTaskState::Running => ("●", STATUS_RUNNING), WaveTaskState::Done => ("✓", STATUS_COMPLETED), WaveTaskState::Blocked => ("◌", TEXT_MUTED), WaveTaskState::InProgress => ("◐", STATUS_RUNNING), };
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 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 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 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 let inner_height = area.height.saturating_sub(3) as usize; 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 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 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 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)); let inner = block.inner(area);
552 frame.render_widget(block, area);
553
554 let visible_height = inner.height as usize;
556 let output = &app.live_output;
557
558 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 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 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 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 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)); 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}