Skip to main content

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 with status filtering
6//! - Bottom: Live terminal output from selected agent with scrolling
7//!
8//! Integrates ported components from components/ for enhanced functionality:
9//! - StreamingView: Rich terminal output with scrolling and line types
10//! - AgentSelector: Agent list with filtering and selection
11//! - Theme system: JSON-based themes for consistent styling
12//!
13//! Minimalist Zen aesthetic with calm colors and clean typography.
14
15use 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
32/// Main render function
33pub fn render(frame: &mut Frame, app: &mut App) {
34    let area = frame.area();
35
36    // Fill background
37    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    // Help overlay (on top of everything)
49    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    // Main layout: header, three panels, footer
56    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    // Fullscreen: small header + terminal + small footer
70    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    // Use integrated StreamingView component for fullscreen terminal output
79    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    // Input view: header + terminal + input bar + footer
85    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    // Use integrated StreamingView component for terminal output in input mode
95    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)), // Cursor
106    ]))
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
133// ─────────────────────────────────────────────────────────────────────────────
134// Integrated Component Renderers
135//
136// These functions use the ported components from components/ for enhanced
137// functionality including StatefulWidget patterns, filtering, and rich styling.
138// ─────────────────────────────────────────────────────────────────────────────
139
140/// Render the output panel using the StreamingViewStrings component
141///
142/// Uses the ported StreamingView component for enhanced terminal output rendering
143/// with proper scrollbar support and line styling.
144fn 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    // Create title based on context
148    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    // Create streaming view state from app state
157    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    // Create and render the streaming view widget
163    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    // Sync state back to app (for scroll position updates)
172    app.scroll_offset = view_state.scroll_offset;
173    app.auto_scroll = view_state.auto_scroll;
174}
175
176/// Render the agents panel using the AgentSelector component
177///
178/// Uses the ported AgentSelector component for enhanced agent display
179/// with filtering support and rich status indicators.
180fn 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    // Convert app agents to AgentInfo format for the component
186    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    // Create selector state from app state
202    let mut selector_state = AgentSelectorState::new(app.selected);
203    selector_state.offset = app.agents_scroll_offset;
204
205    // Calculate visible height for scroll adjustment
206    let inner_height = area.height.saturating_sub(3) as usize;
207    selector_state.adjust_scroll(inner_height);
208
209    // Render using the AgentSelector component
210    let selector = AgentSelector::new(&agents).focused(is_focused).compact(false);
211
212    frame.render_stateful_widget(selector, area, &mut selector_state);
213
214    // Sync state back to app
215    app.agents_scroll_offset = selector_state.offset;
216}
217
218fn render_three_panel_content(frame: &mut Frame, area: Rect, app: &mut App) {
219    // Three vertical panels: waves (top), agents (middle), output (bottom)
220    // Dynamic sizing based on content
221    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), // Waves
227            Constraint::Percentage(25), // Agents
228            Constraint::Percentage(40), // Output
229        ]
230    } else if has_waves {
231        vec![
232            Constraint::Percentage(50), // Waves
233            Constraint::Length(3),      // Agents (minimal)
234            Constraint::Percentage(50), // Output
235        ]
236    } else if has_agents {
237        vec![
238            Constraint::Length(3),      // Waves (minimal)
239            Constraint::Percentage(40), // Agents
240            Constraint::Percentage(60), // Output
241        ]
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 each panel using the integrated components
253    // Waves panel - uses existing render_waves_panel (no equivalent ported component)
254    render_waves_panel(frame, waves_area, app);
255
256    // Agents panel - uses integrated AgentSelector component for enhanced display
257    render_agents_panel_v2(frame, agents_area, app);
258
259    // Output panel - uses integrated StreamingView component for rich output
260    render_output_panel(frame, output_area, app, false);
261}
262
263fn render_footer(frame: &mut Frame, area: Rect, app: &App) {
264    // Context-sensitive help based on focused panel
265    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    // Show error if present
282    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)); // Leave margin
312    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}