1use ratatui::{
16 layout::{Alignment, Constraint, Layout, Rect},
17 style::{Modifier, Style, Stylize},
18 text::{Line, Span},
19 widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph},
20 Frame,
21};
22
23use super::app::{App, FocusedPanel, ViewMode};
24use super::components::{
25 AgentDisplayStatus, AgentInfo, AgentSelector, AgentSelectorState, StreamingView,
26 StreamingViewState,
27};
28use super::header::{render_fullscreen_header, render_header};
29use super::theme::*;
30use super::waves::render_waves_panel;
31
32pub fn render(frame: &mut Frame, app: &mut App) {
34 let area = frame.area();
35
36 frame.render_widget(
38 Block::default().style(Style::default().bg(BG_PRIMARY)),
39 area,
40 );
41
42 match app.view_mode {
43 ViewMode::Split => render_split_view(frame, area, app),
44 ViewMode::Fullscreen => render_fullscreen_view(frame, area, app),
45 ViewMode::Input => render_input_view(frame, area, app),
46 }
47
48 if app.show_help {
50 render_help_overlay(frame, area, app);
51 }
52}
53
54fn render_split_view(frame: &mut Frame, area: Rect, app: &mut App) {
55 let [header_area, content_area, footer_area] = Layout::vertical([
57 Constraint::Length(3),
58 Constraint::Fill(1),
59 Constraint::Length(2),
60 ])
61 .areas(area);
62
63 render_header(frame, header_area, app);
64 render_three_panel_content(frame, content_area, app);
65 render_footer(frame, footer_area, app);
66}
67
68fn render_fullscreen_view(frame: &mut Frame, area: Rect, app: &mut App) {
69 let [header_area, terminal_area, footer_area] = Layout::vertical([
71 Constraint::Length(2),
72 Constraint::Fill(1),
73 Constraint::Length(2),
74 ])
75 .areas(area);
76
77 render_fullscreen_header(frame, header_area, app);
78 render_output_panel(frame, terminal_area, app, true);
80 render_fullscreen_footer(frame, footer_area);
81}
82
83fn render_input_view(frame: &mut Frame, area: Rect, app: &mut App) {
84 let [header_area, terminal_area, input_area, footer_area] = Layout::vertical([
86 Constraint::Length(2),
87 Constraint::Fill(1),
88 Constraint::Length(3),
89 Constraint::Length(2),
90 ])
91 .areas(area);
92
93 render_fullscreen_header(frame, header_area, app);
94 render_output_panel(frame, terminal_area, app, true);
96 render_input_bar(frame, input_area, app);
97 render_input_footer(frame, footer_area);
98}
99
100fn render_input_bar(frame: &mut Frame, area: Rect, app: &App) {
101 let input_text = format!("▸ {}", app.input_buffer);
102
103 let input = Paragraph::new(Line::from(vec![
104 Span::styled(&input_text, Style::default().fg(TEXT_PRIMARY)),
105 Span::styled("█", Style::default().fg(ACCENT)), ]))
107 .block(
108 Block::default()
109 .borders(Borders::ALL)
110 .border_type(BorderType::Rounded)
111 .border_style(Style::default().fg(ACCENT))
112 .title(Line::from(" Send to Agent ").fg(ACCENT))
113 .style(Style::default().bg(BG_SECONDARY))
114 .padding(Padding::horizontal(1)),
115 );
116
117 frame.render_widget(input, area);
118}
119
120fn render_input_footer(frame: &mut Frame, area: Rect) {
121 let help_text = " Enter Send · Esc Cancel · Type your message... ";
122
123 let footer = Paragraph::new(Line::from(vec![Span::styled(
124 help_text,
125 Style::default().fg(TEXT_MUTED),
126 )]))
127 .alignment(Alignment::Center)
128 .style(Style::default().bg(BG_PRIMARY));
129
130 frame.render_widget(footer, area);
131}
132
133fn render_output_panel(frame: &mut Frame, area: Rect, app: &mut App, fullscreen: bool) {
145 let is_focused = app.focused_panel == FocusedPanel::Output || fullscreen;
146
147 let title = if fullscreen {
149 " Terminal (Esc to exit) ".to_string()
150 } else if let Some(agent) = app.selected_agent() {
151 format!(" Output: {} ", agent.task_id)
152 } else {
153 " Live Output ".to_string()
154 };
155
156 let mut view_state = StreamingViewState::new();
158 view_state.scroll_offset = app.scroll_offset;
159 view_state.auto_scroll = app.auto_scroll;
160 view_state.set_total_lines(app.live_output.len());
161
162 let streaming_view = StreamingView::from_strings(&app.live_output)
164 .focused(is_focused)
165 .title(title)
166 .show_scrollbar(true)
167 .fullscreen(fullscreen);
168
169 frame.render_stateful_widget(streaming_view, area, &mut view_state);
170
171 app.scroll_offset = view_state.scroll_offset;
173 app.auto_scroll = view_state.auto_scroll;
174}
175
176fn render_agents_panel_v2(frame: &mut Frame, area: Rect, app: &mut App) {
181 use crate::commands::spawn::monitor::AgentStatus;
182
183 let is_focused = app.focused_panel == FocusedPanel::Agents;
184
185 let agents: Vec<AgentInfo> = app
187 .agents()
188 .iter()
189 .map(|agent| {
190 let status = match agent.status {
191 AgentStatus::Starting => AgentDisplayStatus::Starting,
192 AgentStatus::Running => AgentDisplayStatus::Running,
193 AgentStatus::Completed => AgentDisplayStatus::Completed,
194 AgentStatus::Failed => AgentDisplayStatus::Failed,
195 };
196 AgentInfo::new(&agent.task_id, &agent.task_id, status)
197 .with_task_title(&agent.task_title)
198 })
199 .collect();
200
201 let mut selector_state = AgentSelectorState::new(app.selected);
203 selector_state.offset = app.agents_scroll_offset;
204
205 let inner_height = area.height.saturating_sub(3) as usize;
207 selector_state.adjust_scroll(inner_height);
208
209 let selector = AgentSelector::new(&agents).focused(is_focused).compact(false);
211
212 frame.render_stateful_widget(selector, area, &mut selector_state);
213
214 app.agents_scroll_offset = selector_state.offset;
216}
217
218fn render_three_panel_content(frame: &mut Frame, area: Rect, app: &mut App) {
219 let has_agents = !app.agents().is_empty();
222 let has_waves = !app.waves.is_empty();
223
224 let constraints = if has_waves && has_agents {
225 vec![
226 Constraint::Percentage(35), Constraint::Percentage(25), Constraint::Percentage(40), ]
230 } else if has_waves {
231 vec![
232 Constraint::Percentage(50), Constraint::Length(3), Constraint::Percentage(50), ]
236 } else if has_agents {
237 vec![
238 Constraint::Length(3), Constraint::Percentage(40), Constraint::Percentage(60), ]
242 } else {
243 vec![
244 Constraint::Length(3),
245 Constraint::Length(3),
246 Constraint::Fill(1),
247 ]
248 };
249
250 let [waves_area, agents_area, output_area] = Layout::vertical(constraints).areas(area);
251
252 render_waves_panel(frame, waves_area, app);
255
256 render_agents_panel_v2(frame, agents_area, app);
258
259 render_output_panel(frame, output_area, app, false);
261}
262
263fn render_footer(frame: &mut Frame, area: Rect, app: &App) {
264 let ralph_hint = if app.ralph_mode {
266 "R Ralph OFF"
267 } else {
268 "R Ralph"
269 };
270 let help_text = match app.focused_panel {
271 FocusedPanel::Waves => format!(" Tab Panel · j/k Navigate · Space Select · a All · s Spawn · W Swarm · {} · ? Help · q Quit ", ralph_hint),
272 FocusedPanel::Agents => format!(" Tab Panel · j/k Navigate · d Done · p Pending · b Blocked · W Swarm · {} · ? Help · q Quit ", ralph_hint),
273 FocusedPanel::Output => format!(" Tab Panel · ↑↓ Scroll · G Bottom · Enter Fullscreen · W Swarm · {} · ? Help · q Quit ", ralph_hint),
274 };
275
276 let mut line = Line::from(vec![Span::styled(
277 help_text,
278 Style::default().fg(TEXT_MUTED),
279 )]);
280
281 if let Some(ref error) = app.error {
283 line = Line::from(vec![
284 Span::styled(" ⚠ ", Style::default().fg(ERROR)),
285 Span::styled(error.as_str(), Style::default().fg(ERROR)),
286 ]);
287 }
288
289 let footer = Paragraph::new(line)
290 .alignment(Alignment::Center)
291 .style(Style::default().bg(BG_PRIMARY));
292
293 frame.render_widget(footer, area);
294}
295
296fn render_fullscreen_footer(frame: &mut Frame, area: Rect) {
297 let help_text = " ↑↓ Scroll · j/k Switch · G Bottom · i Input · Esc Back · q Quit ";
298
299 let footer = Paragraph::new(Line::from(vec![Span::styled(
300 help_text,
301 Style::default().fg(TEXT_MUTED),
302 )]))
303 .alignment(Alignment::Center)
304 .style(Style::default().bg(BG_PRIMARY));
305
306 frame.render_widget(footer, area);
307}
308
309fn render_help_overlay(frame: &mut Frame, area: Rect, app: &App) {
310 let overlay_width = 55.min(area.width.saturating_sub(4));
311 let overlay_height = 22.min(area.height.saturating_sub(2)); let x = (area.width.saturating_sub(overlay_width)) / 2;
313 let y = (area.height.saturating_sub(overlay_height)) / 2;
314 let overlay_area = Rect::new(x, y, overlay_width, overlay_height);
315
316 frame.render_widget(Clear, overlay_area);
317
318 let mode_hint = match app.view_mode {
319 ViewMode::Split => "Three-Panel",
320 ViewMode::Fullscreen => "Fullscreen",
321 ViewMode::Input => "Input Mode",
322 };
323
324 let panel_hint = match app.focused_panel {
325 FocusedPanel::Waves => "Waves",
326 FocusedPanel::Agents => "Agents",
327 FocusedPanel::Output => "Output",
328 };
329
330 let help_text = vec![
331 Line::from(vec![
332 Span::styled(" Tab ", Style::default().fg(ACCENT)),
333 Span::styled("Panel ", Style::default().fg(TEXT_PRIMARY)),
334 Span::styled(" j/k ", Style::default().fg(ACCENT)),
335 Span::styled("Navigate ", Style::default().fg(TEXT_PRIMARY)),
336 Span::styled(" r ", Style::default().fg(ACCENT)),
337 Span::styled("Refresh", Style::default().fg(TEXT_PRIMARY)),
338 ]),
339 Line::from(""),
340 Line::from(Span::styled(" Waves:", Style::default().fg(TEXT_MUTED))),
341 Line::from(vec![
342 Span::styled(" Space ", Style::default().fg(ACCENT)),
343 Span::styled("Select ", Style::default().fg(TEXT_PRIMARY)),
344 Span::styled(" a ", Style::default().fg(ACCENT)),
345 Span::styled("All ", Style::default().fg(TEXT_PRIMARY)),
346 Span::styled(" c ", Style::default().fg(ACCENT)),
347 Span::styled("Clear ", Style::default().fg(TEXT_PRIMARY)),
348 Span::styled(" s ", Style::default().fg(ACCENT)),
349 Span::styled("Spawn", Style::default().fg(TEXT_PRIMARY)),
350 ]),
351 Line::from(""),
352 Line::from(Span::styled(" Agents:", Style::default().fg(TEXT_MUTED))),
353 Line::from(vec![
354 Span::styled(" Enter ", Style::default().fg(ACCENT)),
355 Span::styled("View ", Style::default().fg(TEXT_PRIMARY)),
356 Span::styled(" i ", Style::default().fg(ACCENT)),
357 Span::styled("Input ", Style::default().fg(TEXT_PRIMARY)),
358 Span::styled(" x ", Style::default().fg(ACCENT)),
359 Span::styled("Stop", Style::default().fg(TEXT_PRIMARY)),
360 ]),
361 Line::from(vec![
362 Span::styled(" d ", Style::default().fg(ACCENT)),
363 Span::styled("Done ", Style::default().fg(TEXT_PRIMARY)),
364 Span::styled(" p ", Style::default().fg(ACCENT)),
365 Span::styled("Pending ", Style::default().fg(TEXT_PRIMARY)),
366 Span::styled(" b ", Style::default().fg(ACCENT)),
367 Span::styled("Blocked", Style::default().fg(TEXT_PRIMARY)),
368 ]),
369 Line::from(""),
370 Line::from(Span::styled(" Output:", Style::default().fg(TEXT_MUTED))),
371 Line::from(vec![
372 Span::styled(" ↑/↓ ", Style::default().fg(ACCENT)),
373 Span::styled("Scroll ", Style::default().fg(TEXT_PRIMARY)),
374 Span::styled(" G ", Style::default().fg(ACCENT)),
375 Span::styled("Bottom ", Style::default().fg(TEXT_PRIMARY)),
376 Span::styled(" Enter ", Style::default().fg(ACCENT)),
377 Span::styled("Fullscreen", Style::default().fg(TEXT_PRIMARY)),
378 ]),
379 Line::from(""),
380 Line::from(vec![
381 Span::styled(" W ", Style::default().fg(ACCENT)),
382 Span::styled("Start Swarm ", Style::default().fg(TEXT_PRIMARY)),
383 Span::styled(" ? ", Style::default().fg(ACCENT)),
384 Span::styled("Help ", Style::default().fg(TEXT_PRIMARY)),
385 Span::styled(" q ", Style::default().fg(ACCENT)),
386 Span::styled("Quit", Style::default().fg(TEXT_PRIMARY)),
387 ]),
388 Line::from(""),
389 Line::from(vec![Span::styled(
390 format!(" Mode: {} | Panel: {}", mode_hint, panel_hint),
391 Style::default().fg(TEXT_MUTED),
392 )]),
393 ];
394
395 let help_block = Block::default()
396 .borders(Borders::ALL)
397 .border_type(BorderType::Rounded)
398 .border_style(Style::default().fg(ACCENT))
399 .title(
400 Line::from(" Keybindings ")
401 .fg(ACCENT)
402 .add_modifier(Modifier::BOLD),
403 )
404 .title_alignment(Alignment::Center)
405 .style(Style::default().bg(BG_SECONDARY));
406
407 let help_para = Paragraph::new(help_text).block(help_block);
408 frame.render_widget(help_para, overlay_area);
409}