Skip to main content

scud/commands/spawn/tui/
waves.rs

1//! Waves panel view for TUI monitor
2//!
3//! Displays tasks organized by execution wave, with selection support for spawning.
4
5use ratatui::{
6    layout::Rect,
7    style::{Modifier, Style, Stylize},
8    text::{Line, Span},
9    widgets::{Block, BorderType, Borders, List, ListItem, Padding, Paragraph},
10    Frame,
11};
12
13use super::app::{App, FocusedPanel, WaveTaskState};
14use super::theme::*;
15
16/// Render the waves panel showing tasks by execution wave
17pub fn render_waves_panel(frame: &mut Frame, area: Rect, app: &App) {
18    let is_focused = app.focused_panel == FocusedPanel::Waves;
19    let border_color = if is_focused {
20        BORDER_ACTIVE
21    } else {
22        BORDER_DEFAULT
23    };
24    let title_color = if is_focused { ACCENT } else { TEXT_MUTED };
25
26    let ready_count = app.ready_task_count();
27    let selected_count = app.selected_task_count();
28    let title = if app.swarm_mode {
29        // Swarm mode: show SWARM indicator and wave count
30        let wave_count = app.waves.len();
31        format!(" SWARM Waves ({} waves) ", wave_count)
32    } else if selected_count > 0 {
33        format!(
34            " Waves & Tasks ({} selected / {} ready) ",
35            selected_count, ready_count
36        )
37    } else {
38        format!(" Waves & Tasks ({} ready) ", ready_count)
39    };
40
41    let block = Block::default()
42        .borders(Borders::ALL)
43        .border_type(BorderType::Rounded)
44        .border_style(Style::default().fg(border_color))
45        .title(Line::from(title).fg(title_color))
46        .style(Style::default().bg(BG_SECONDARY))
47        .padding(Padding::new(1, 1, 0, 0));
48
49    if app.waves.is_empty() {
50        let empty_msg = Paragraph::new("No actionable tasks")
51            .style(Style::default().fg(TEXT_MUTED))
52            .block(block);
53        frame.render_widget(empty_msg, area);
54        return;
55    }
56
57    // Build list items from waves
58    let mut all_items: Vec<ListItem> = Vec::new();
59    let mut task_index = 0;
60
61    for wave in &app.waves {
62        // Wave header - different format for swarm vs spawn mode
63        let wave_header = if app.swarm_mode {
64            build_swarm_wave_header(app, wave)
65        } else {
66            build_spawn_wave_header(wave)
67        };
68        all_items.push(ListItem::new(wave_header));
69
70        // Tasks in wave
71        for task in &wave.tasks {
72            let is_selected_in_list = task_index == app.wave_task_index && is_focused;
73            let is_selected_for_spawn = app.selected_tasks.contains(&task.id);
74
75            let line = build_task_line(task, is_selected_in_list, is_selected_for_spawn);
76            all_items.push(ListItem::new(line));
77            task_index += 1;
78        }
79    }
80
81    // Apply scroll offset - skip first N items
82    let visible_items: Vec<ListItem> = all_items.into_iter().skip(app.wave_scroll_offset).collect();
83
84    let list = List::new(visible_items).block(block);
85    frame.render_widget(list, area);
86}
87
88/// Build the wave header line for swarm mode
89fn build_swarm_wave_header(app: &App, wave: &super::app::Wave) -> Line<'static> {
90    // In swarm mode, show validation status from actual wave data
91    let validation_info = if let Some(ref swarm) = app.swarm_session_data {
92        if let Some(wave_state) = swarm.waves.iter().find(|w| w.wave_number == wave.number) {
93            match &wave_state.validation {
94                Some(v) if v.all_passed => ("✓", STATUS_COMPLETED, "VALIDATED"),
95                Some(_) => ("✗", FAILED_VALIDATION_RED, "FAILED"),
96                None if wave_state.completed_at.is_some() => ("◌", TEXT_MUTED, "NO VALIDATION"),
97                None => ("●", STATUS_RUNNING, "IN PROGRESS"),
98            }
99        } else {
100            ("◌", TEXT_MUTED, "PENDING")
101        }
102    } else {
103        ("◌", TEXT_MUTED, "PENDING")
104    };
105
106    let round_count = if let Some(ref swarm) = app.swarm_session_data {
107        swarm
108            .waves
109            .iter()
110            .find(|w| w.wave_number == wave.number)
111            .map(|w| w.rounds.len())
112            .unwrap_or(0)
113    } else {
114        0
115    };
116
117    let repair_count = if let Some(ref swarm) = app.swarm_session_data {
118        swarm
119            .waves
120            .iter()
121            .find(|w| w.wave_number == wave.number)
122            .map(|w| w.repairs.len())
123            .unwrap_or(0)
124    } else {
125        0
126    };
127
128    let repair_info = if repair_count > 0 {
129        format!(" [{} repairs]", repair_count)
130    } else {
131        String::new()
132    };
133
134    Line::from(vec![
135        Span::styled(
136            format!("Wave {} ", wave.number),
137            Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
138        ),
139        Span::styled(
140            format!("{} ", validation_info.0),
141            Style::default().fg(validation_info.1),
142        ),
143        Span::styled(
144            format!("{} ", validation_info.2),
145            Style::default().fg(validation_info.1),
146        ),
147        Span::styled(
148            format!(
149                "({} tasks, {} rounds{})",
150                wave.tasks.len(),
151                round_count,
152                repair_info
153            ),
154            Style::default().fg(TEXT_MUTED),
155        ),
156    ])
157}
158
159/// Build the wave header line for spawn mode
160fn build_spawn_wave_header(wave: &super::app::Wave) -> Line<'static> {
161    let ready_in_wave = wave
162        .tasks
163        .iter()
164        .filter(|t| t.state == WaveTaskState::Ready)
165        .count();
166
167    Line::from(vec![
168        Span::styled(
169            format!("Wave {} ", wave.number),
170            Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
171        ),
172        Span::styled(
173            format!("({} tasks, {} ready)", wave.tasks.len(), ready_in_wave),
174            Style::default().fg(TEXT_MUTED),
175        ),
176    ])
177}
178
179/// Build a task line for display in the waves panel
180fn build_task_line(
181    task: &super::app::WaveTask,
182    is_selected_in_list: bool,
183    is_selected_for_spawn: bool,
184) -> Line<'static> {
185    let state_icon = match task.state {
186        WaveTaskState::Ready => ("○", STATUS_COMPLETED), // Blue circle = ready
187        WaveTaskState::Running => ("●", STATUS_RUNNING), // Green filled = running
188        WaveTaskState::Done => ("✓", STATUS_COMPLETED),  // Blue check = done
189        WaveTaskState::Blocked => ("◌", TEXT_MUTED),     // Hollow = blocked
190        WaveTaskState::InProgress => ("◐", STATUS_RUNNING), // Half = in progress
191    };
192
193    let checkbox = if is_selected_for_spawn {
194        "[x]"
195    } else if task.state == WaveTaskState::Ready {
196        "[ ]"
197    } else {
198        "   "
199    };
200
201    // Truncate title
202    let max_len = 40;
203    let title_display = if task.title.len() > max_len {
204        format!("{}…", &task.title[..max_len - 1])
205    } else {
206        task.title.clone()
207    };
208
209    let complexity = if task.complexity > 0 {
210        format!(" [{}]", task.complexity)
211    } else {
212        String::new()
213    };
214
215    Line::from(vec![
216        Span::styled(
217            if is_selected_in_list { "▸ " } else { "  " },
218            Style::default().fg(ACCENT),
219        ),
220        Span::styled(
221            format!("{} ", checkbox),
222            Style::default().fg(if is_selected_for_spawn {
223                ACCENT
224            } else {
225                TEXT_MUTED
226            }),
227        ),
228        Span::styled(
229            format!("{} ", state_icon.0),
230            Style::default().fg(state_icon.1),
231        ),
232        Span::styled(format!("{} ", task.id), Style::default().fg(TEXT_MUTED)),
233        Span::styled(
234            title_display,
235            Style::default()
236                .fg(if is_selected_in_list {
237                    ACCENT
238                } else {
239                    TEXT_PRIMARY
240                })
241                .add_modifier(if is_selected_in_list {
242                    Modifier::BOLD
243                } else {
244                    Modifier::empty()
245                }),
246        ),
247        Span::styled(complexity, Style::default().fg(TEXT_MUTED)),
248    ])
249}