scud/commands/spawn/tui/
app.rs

1//! Application state 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//! Tab switches focus between panels. Space toggles task selection for spawning.
9
10use anyhow::Result;
11use std::collections::{HashMap, HashSet};
12use std::path::PathBuf;
13use std::process::Command;
14use std::time::{Duration, Instant};
15
16use crate::commands::spawn::monitor::{
17    load_session, save_session, AgentState, AgentStatus, SpawnSession,
18};
19use crate::commands::swarm::session as swarm_session;
20use crate::models::phase::Phase;
21use crate::models::task::{Task, TaskStatus};
22use crate::storage::Storage;
23
24/// View mode for the TUI
25#[derive(Debug, Clone, PartialEq)]
26pub enum ViewMode {
27    /// Three-panel view: waves + agents + output
28    Split,
29    /// Fullscreen: just the selected agent's terminal
30    Fullscreen,
31    /// Input mode: typing a command to send to agent
32    Input,
33}
34
35/// Which panel is focused
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub enum FocusedPanel {
38    Waves,
39    Agents,
40    Output,
41}
42
43/// Task state in the waves view
44#[derive(Debug, Clone, PartialEq)]
45pub enum WaveTaskState {
46    /// Ready to spawn (dependencies met, pending)
47    Ready,
48    /// Currently running (agent spawned)
49    Running,
50    /// Completed
51    Done,
52    /// Blocked by dependencies
53    Blocked,
54    /// In progress but no agent visible
55    InProgress,
56}
57
58/// Information about a task in a wave
59#[derive(Debug, Clone)]
60pub struct WaveTask {
61    pub id: String,
62    pub title: String,
63    pub tag: String,
64    pub state: WaveTaskState,
65    pub complexity: u32,
66    pub dependencies: Vec<String>,
67}
68
69/// A wave of tasks that can run in parallel
70#[derive(Debug, Clone)]
71pub struct Wave {
72    pub number: usize,
73    pub tasks: Vec<WaveTask>,
74}
75
76/// Progress tracking for swarm execution
77///
78/// Provides a consolidated view of swarm progress including:
79/// - Current/total waves
80/// - Task completion counts
81/// - Validation status
82#[derive(Debug, Clone, Default)]
83pub struct SwarmProgress {
84    /// Current wave number being executed (1-indexed, 0 if not started)
85    pub current_wave: usize,
86    /// Total number of waves in the execution plan
87    pub total_waves: usize,
88    /// Number of tasks completed successfully (status = Done)
89    pub tasks_completed: usize,
90    /// Total number of tasks in the swarm
91    pub tasks_total: usize,
92    /// Number of tasks currently in progress
93    pub tasks_in_progress: usize,
94    /// Number of tasks that failed or are blocked
95    pub tasks_failed: usize,
96    /// Number of waves that have passed validation
97    pub waves_validated: usize,
98    /// Number of waves that failed validation
99    pub waves_failed_validation: usize,
100    /// Total repair attempts across all waves
101    pub total_repairs: usize,
102}
103
104/// Application state
105pub struct App {
106    /// Project root directory
107    pub project_root: Option<PathBuf>,
108    /// Session name being monitored
109    pub session_name: String,
110    /// Current spawn session data
111    pub session: Option<SpawnSession>,
112    /// Selected agent index
113    pub selected: usize,
114    /// Current view mode
115    pub view_mode: ViewMode,
116    /// Show help overlay
117    pub show_help: bool,
118    /// Last refresh time
119    last_refresh: Instant,
120    /// Refresh interval
121    refresh_interval: Duration,
122    /// Error message to display
123    pub error: Option<String>,
124    /// Live terminal output for selected agent (cached)
125    pub live_output: Vec<String>,
126    /// Last output refresh
127    last_output_refresh: Instant,
128    /// Output refresh interval (faster than status refresh)
129    output_refresh_interval: Duration,
130    /// Input buffer for sending commands to agent
131    pub input_buffer: String,
132    /// Scroll offset for terminal output (0 = bottom, positive = scrolled up)
133    pub scroll_offset: usize,
134    /// Auto-scroll to bottom on new output
135    pub auto_scroll: bool,
136
137    // === New three-panel state ===
138    /// Which panel is currently focused
139    pub focused_panel: FocusedPanel,
140    /// Execution waves with tasks
141    pub waves: Vec<Wave>,
142    /// Selected task IDs for batch spawning
143    pub selected_tasks: HashSet<String>,
144    /// Selected task index within waves panel
145    pub wave_task_index: usize,
146    /// Scroll offset for waves panel (first visible line)
147    pub wave_scroll_offset: usize,
148    /// Scroll offset for agents panel (first visible line)
149    pub agents_scroll_offset: usize,
150    /// Active tag for loading waves
151    pub active_tag: Option<String>,
152    /// All phases data (cached)
153    phases: HashMap<String, Phase>,
154
155    // === Ralph Mode (autonomous wave execution) ===
156    /// Whether Ralph mode is enabled (auto-spawn ready tasks)
157    pub ralph_mode: bool,
158    /// Maximum parallel agents in Ralph mode
159    pub ralph_max_parallel: usize,
160    /// Last time we checked for new tasks to spawn in Ralph mode
161    last_ralph_check: Instant,
162
163    // === Swarm Mode ===
164    /// Whether we're monitoring a swarm session (vs spawn session)
165    pub swarm_mode: bool,
166    /// Swarm session data (when swarm_mode is true)
167    pub swarm_session_data: Option<swarm_session::SwarmSession>,
168    /// Swarm progress tracking (computed from swarm_session_data and phases)
169    pub swarm_progress: Option<SwarmProgress>,
170}
171
172impl App {
173    /// Create new app state
174    pub fn new(
175        project_root: Option<PathBuf>,
176        session_name: &str,
177        swarm_mode: bool,
178    ) -> Result<Self> {
179        // Load storage to get active tag and phases
180        let storage = Storage::new(project_root.clone());
181        let active_tag = storage.get_active_group().ok().flatten();
182        let phases = storage.load_tasks().unwrap_or_default();
183
184        let mut app = Self {
185            project_root,
186            session_name: session_name.to_string(),
187            session: None,
188            selected: 0,
189            view_mode: ViewMode::Split,
190            show_help: false,
191            last_refresh: Instant::now(),
192            refresh_interval: Duration::from_secs(2),
193            error: None,
194            live_output: Vec::new(),
195            last_output_refresh: Instant::now(),
196            output_refresh_interval: Duration::from_millis(500),
197            input_buffer: String::new(),
198            scroll_offset: 0,
199            auto_scroll: true,
200            // New fields
201            focused_panel: FocusedPanel::Waves,
202            waves: Vec::new(),
203            selected_tasks: HashSet::new(),
204            wave_task_index: 0,
205            wave_scroll_offset: 0,
206            agents_scroll_offset: 0,
207            active_tag,
208            phases,
209            // Ralph mode
210            ralph_mode: false,
211            ralph_max_parallel: 5,
212            last_ralph_check: Instant::now(),
213            // Swarm mode
214            swarm_mode,
215            swarm_session_data: None,
216            swarm_progress: None,
217        };
218        app.refresh()?;
219        app.refresh_waves();
220        app.refresh_live_output();
221        Ok(app)
222    }
223
224    /// Refresh session data from disk and update agent statuses
225    pub fn refresh(&mut self) -> Result<()> {
226        if self.swarm_mode {
227            // Load swarm session
228            match swarm_session::load_session(self.project_root.as_ref(), &self.session_name) {
229                Ok(session) => {
230                    self.swarm_session_data = Some(session);
231                    self.error = None;
232                    // Update swarm progress after loading session
233                    self.swarm_progress = self.compute_swarm_progress();
234                }
235                Err(e) => {
236                    self.error = Some(format!("Failed to load swarm session: {}", e));
237                    self.swarm_progress = None;
238                }
239            }
240        } else {
241            // Load spawn session
242            match load_session(self.project_root.as_ref(), &self.session_name) {
243                Ok(mut session) => {
244                    // Update agent statuses from tmux and SCUD task status
245                    self.refresh_agent_statuses(&mut session);
246
247                    // Save updated session back to disk
248                    let _ = save_session(self.project_root.as_ref(), &session);
249
250                    self.session = Some(session);
251                    self.error = None;
252                }
253                Err(e) => {
254                    self.error = Some(format!("Failed to load session: {}", e));
255                }
256            }
257        }
258        self.last_refresh = Instant::now();
259        Ok(())
260    }
261
262    /// Refresh live output from the selected agent's tmux pane
263    pub fn refresh_live_output(&mut self) {
264        let agents = self.agents();
265        if agents.is_empty() || self.selected >= agents.len() {
266            self.live_output = vec!["No agent selected".to_string()];
267            return;
268        }
269
270        let agent = &agents[self.selected];
271        let session = match &self.session {
272            Some(s) => s,
273            None => {
274                self.live_output = vec!["No session loaded".to_string()];
275                return;
276            }
277        };
278
279        // Get tmux windows to find the correct window index
280        let tmux_windows = self.get_tmux_windows(&session.session_name);
281        let matching_window = tmux_windows.iter().find(|(_, name)| {
282            name.starts_with(&agent.window_name) || agent.window_name.starts_with(name)
283        });
284
285        let window_target = match matching_window {
286            Some((index, _)) => format!("{}:{}", session.session_name, index),
287            None => {
288                self.live_output = vec![format!("Window '{}' not found", agent.window_name)];
289                return;
290            }
291        };
292
293        // Capture pane content with scrollback
294        let output = Command::new("tmux")
295            .args([
296                "capture-pane",
297                "-t",
298                &window_target,
299                "-p", // print to stdout
300                "-S",
301                "-100", // start from 100 lines back
302            ])
303            .output();
304
305        match output {
306            Ok(out) if out.status.success() => {
307                let content = String::from_utf8_lossy(&out.stdout);
308                self.live_output = content.lines().map(|s| s.to_string()).collect();
309
310                // Remove trailing empty lines
311                while self
312                    .live_output
313                    .last()
314                    .map(|s| s.trim().is_empty())
315                    .unwrap_or(false)
316                {
317                    self.live_output.pop();
318                }
319            }
320            Ok(out) => {
321                self.live_output = vec![format!("Error: {}", String::from_utf8_lossy(&out.stderr))];
322            }
323            Err(e) => {
324                self.live_output = vec![format!("tmux error: {}", e)];
325            }
326        }
327
328        self.last_output_refresh = Instant::now();
329    }
330
331    /// Refresh agent statuses by checking tmux windows and SCUD task status
332    fn refresh_agent_statuses(&self, session: &mut SpawnSession) {
333        let tmux_windows = self.get_tmux_windows(&session.session_name);
334        let storage = Storage::new(self.project_root.clone());
335        let all_phases = storage.load_tasks().ok();
336
337        for agent in &mut session.agents {
338            let window_exists = tmux_windows.iter().any(|(_, name)| {
339                name.starts_with(&agent.window_name) || agent.window_name.starts_with(name)
340            });
341
342            let task_status = all_phases.as_ref().and_then(|phases| {
343                phases.get(&agent.tag).and_then(|phase| {
344                    phase
345                        .get_task(&agent.task_id)
346                        .map(|task| task.status.clone())
347                })
348            });
349
350            agent.status = match (&task_status, window_exists) {
351                (Some(TaskStatus::Done), _) => AgentStatus::Completed,
352                (Some(TaskStatus::Blocked), _) => AgentStatus::Failed,
353                (Some(TaskStatus::InProgress), true) => AgentStatus::Running,
354                (Some(TaskStatus::InProgress), false) => AgentStatus::Completed,
355                (_, false) => AgentStatus::Completed,
356                (_, true) => AgentStatus::Running,
357            };
358        }
359    }
360
361    /// Get list of tmux windows for a session: [(index, name), ...]
362    fn get_tmux_windows(&self, session_name: &str) -> Vec<(usize, String)> {
363        let output = Command::new("tmux")
364            .args([
365                "list-windows",
366                "-t",
367                session_name,
368                "-F",
369                "#{window_index}:#{window_name}",
370            ])
371            .output();
372
373        match output {
374            Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
375                .lines()
376                .filter_map(|line| {
377                    let parts: Vec<&str> = line.splitn(2, ':').collect();
378                    if parts.len() == 2 {
379                        parts[0].parse().ok().map(|idx| (idx, parts[1].to_string()))
380                    } else {
381                        None
382                    }
383                })
384                .collect(),
385            _ => Vec::new(),
386        }
387    }
388
389    /// Periodic tick - refresh data as needed
390    pub fn tick(&mut self) -> Result<()> {
391        // Refresh session/status data periodically
392        if self.last_refresh.elapsed() >= self.refresh_interval {
393            self.refresh()?;
394            self.refresh_waves();
395        }
396
397        // Refresh live output more frequently
398        if self.last_output_refresh.elapsed() >= self.output_refresh_interval {
399            self.refresh_live_output();
400        }
401
402        // Ralph mode: auto-spawn ready tasks
403        if self.ralph_mode && self.last_ralph_check.elapsed() >= Duration::from_secs(5) {
404            self.ralph_auto_spawn();
405            self.last_ralph_check = Instant::now();
406        }
407
408        Ok(())
409    }
410
411    /// Toggle Ralph mode (autonomous wave execution)
412    pub fn toggle_ralph_mode(&mut self) {
413        self.ralph_mode = !self.ralph_mode;
414        if self.ralph_mode {
415            // Immediately check for tasks to spawn
416            self.ralph_auto_spawn();
417        }
418    }
419
420    /// Auto-spawn ready tasks in Ralph mode
421    fn ralph_auto_spawn(&mut self) {
422        // Count running agents
423        let running_count = self
424            .agents()
425            .iter()
426            .filter(|a| a.status == AgentStatus::Running || a.status == AgentStatus::Starting)
427            .count();
428
429        if running_count >= self.ralph_max_parallel {
430            return; // Already at max parallel
431        }
432
433        // Find ready tasks to spawn
434        let slots_available = self.ralph_max_parallel - running_count;
435        let mut tasks_to_spawn: Vec<String> = Vec::new();
436
437        for wave in &self.waves {
438            for task in &wave.tasks {
439                if task.state == WaveTaskState::Ready && !self.selected_tasks.contains(&task.id) {
440                    // Check if already have an agent for this task
441                    let already_spawned = self.agents().iter().any(|a| a.task_id == task.id);
442                    if !already_spawned {
443                        tasks_to_spawn.push(task.id.clone());
444                        if tasks_to_spawn.len() >= slots_available {
445                            break;
446                        }
447                    }
448                }
449            }
450            if tasks_to_spawn.len() >= slots_available {
451                break;
452            }
453        }
454
455        // Spawn the tasks with Ralph loop enabled
456        for task_id in tasks_to_spawn {
457            let _ = self.spawn_task_with_ralph(&task_id);
458        }
459    }
460
461    /// Get agents list
462    pub fn agents(&self) -> &[AgentState] {
463        self.session
464            .as_ref()
465            .map(|s| s.agents.as_slice())
466            .unwrap_or(&[])
467    }
468
469    /// Select next agent
470    pub fn next_agent(&mut self) {
471        let len = self.agents().len();
472        if len > 0 {
473            self.selected = (self.selected + 1) % len;
474            self.adjust_agents_scroll();
475            self.reset_scroll();
476            self.refresh_live_output();
477        }
478    }
479
480    /// Select previous agent
481    pub fn previous_agent(&mut self) {
482        let len = self.agents().len();
483        if len > 0 {
484            self.selected = if self.selected > 0 {
485                self.selected - 1
486            } else {
487                len - 1
488            };
489            self.adjust_agents_scroll();
490            self.reset_scroll();
491            self.refresh_live_output();
492        }
493    }
494
495    /// Adjust agents scroll offset to keep selected agent visible
496    /// Assumes roughly 8 visible lines in the agents panel
497    pub fn adjust_agents_scroll(&mut self) {
498        const VISIBLE_LINES: usize = 8;
499
500        // Scroll up if selected is above visible area
501        if self.selected < self.agents_scroll_offset {
502            self.agents_scroll_offset = self.selected;
503        }
504        // Scroll down if selected is below visible area
505        else if self.selected >= self.agents_scroll_offset + VISIBLE_LINES {
506            self.agents_scroll_offset = self.selected.saturating_sub(VISIBLE_LINES - 1);
507        }
508    }
509
510    /// Toggle fullscreen mode
511    pub fn toggle_fullscreen(&mut self) {
512        self.view_mode = match self.view_mode {
513            ViewMode::Split => ViewMode::Fullscreen,
514            ViewMode::Fullscreen => ViewMode::Split,
515            ViewMode::Input => ViewMode::Fullscreen,
516        };
517    }
518
519    /// Exit current mode (go back to split)
520    pub fn exit_fullscreen(&mut self) {
521        self.view_mode = ViewMode::Split;
522        self.input_buffer.clear();
523    }
524
525    /// Enter input mode
526    pub fn enter_input_mode(&mut self) {
527        self.view_mode = ViewMode::Input;
528        self.input_buffer.clear();
529    }
530
531    /// Add character to input buffer
532    pub fn input_char(&mut self, c: char) {
533        self.input_buffer.push(c);
534    }
535
536    /// Delete last character from input buffer
537    pub fn input_backspace(&mut self) {
538        self.input_buffer.pop();
539    }
540
541    /// Send the input buffer to the selected agent's tmux pane
542    pub fn send_input(&mut self) -> Result<()> {
543        if self.input_buffer.is_empty() {
544            return Ok(());
545        }
546
547        let session = match &self.session {
548            Some(s) => s,
549            None => {
550                self.error = Some("No session loaded".to_string());
551                return Ok(());
552            }
553        };
554
555        let agents = self.agents();
556        if agents.is_empty() || self.selected >= agents.len() {
557            self.error = Some("No agent selected".to_string());
558            return Ok(());
559        }
560
561        let agent = &agents[self.selected];
562
563        // Find window index
564        let tmux_windows = self.get_tmux_windows(&session.session_name);
565        let matching_window = tmux_windows.iter().find(|(_, name)| {
566            name.starts_with(&agent.window_name) || agent.window_name.starts_with(name)
567        });
568
569        let window_target = match matching_window {
570            Some((index, _)) => format!("{}:{}", session.session_name, index),
571            None => {
572                self.error = Some(format!("Window not found for {}", agent.task_id));
573                return Ok(());
574            }
575        };
576
577        // Send the input to tmux
578        let result = Command::new("tmux")
579            .args([
580                "send-keys",
581                "-t",
582                &window_target,
583                &self.input_buffer,
584                "Enter",
585            ])
586            .output();
587
588        match result {
589            Ok(out) if out.status.success() => {
590                self.error = None;
591                self.input_buffer.clear();
592                self.view_mode = ViewMode::Fullscreen; // Go to fullscreen to see result
593                self.refresh_live_output();
594            }
595            Ok(out) => {
596                self.error = Some(format!(
597                    "Send failed: {}",
598                    String::from_utf8_lossy(&out.stderr)
599                ));
600            }
601            Err(e) => {
602                self.error = Some(format!("tmux error: {}", e));
603            }
604        }
605
606        Ok(())
607    }
608
609    /// Restart the selected agent (kill and respawn claude)
610    pub fn restart_agent(&mut self) -> Result<()> {
611        let session = match &self.session {
612            Some(s) => s,
613            None => return Ok(()),
614        };
615
616        let agents = self.agents();
617        if agents.is_empty() || self.selected >= agents.len() {
618            return Ok(());
619        }
620
621        let agent = &agents[self.selected];
622
623        // Find window
624        let tmux_windows = self.get_tmux_windows(&session.session_name);
625        let matching_window = tmux_windows.iter().find(|(_, name)| {
626            name.starts_with(&agent.window_name) || agent.window_name.starts_with(name)
627        });
628
629        if let Some((index, _)) = matching_window {
630            let target = format!("{}:{}", session.session_name, index);
631
632            // Send Ctrl+C to interrupt current process
633            let _ = Command::new("tmux")
634                .args(["send-keys", "-t", &target, "C-c"])
635                .output();
636
637            // Small delay
638            std::thread::sleep(Duration::from_millis(200));
639
640            // Clear and show message
641            let _ = Command::new("tmux")
642                .args([
643                    "send-keys",
644                    "-t",
645                    &target,
646                    "echo 'Agent restarted by user'",
647                    "Enter",
648                ])
649                .output();
650
651            self.error = None;
652            self.refresh_live_output();
653        }
654
655        Ok(())
656    }
657
658    /// Toggle help overlay
659    pub fn toggle_help(&mut self) {
660        self.show_help = !self.show_help;
661    }
662
663    /// Scroll terminal output up (show older content)
664    pub fn scroll_up(&mut self, lines: usize) {
665        let max_scroll = self.live_output.len().saturating_sub(1);
666        self.scroll_offset = (self.scroll_offset + lines).min(max_scroll);
667        self.auto_scroll = false;
668    }
669
670    /// Scroll terminal output down (show newer content)
671    pub fn scroll_down(&mut self, lines: usize) {
672        self.scroll_offset = self.scroll_offset.saturating_sub(lines);
673        if self.scroll_offset == 0 {
674            self.auto_scroll = true;
675        }
676    }
677
678    /// Jump to bottom of terminal output
679    pub fn scroll_to_bottom(&mut self) {
680        self.scroll_offset = 0;
681        self.auto_scroll = true;
682    }
683
684    /// Reset scroll when switching agents
685    fn reset_scroll(&mut self) {
686        self.scroll_offset = 0;
687        self.auto_scroll = true;
688    }
689
690    /// Get status counts (starting, running, completed, failed)
691    pub fn status_counts(&self) -> (usize, usize, usize, usize) {
692        let agents = self.agents();
693        let starting = agents
694            .iter()
695            .filter(|a| a.status == AgentStatus::Starting)
696            .count();
697        let running = agents
698            .iter()
699            .filter(|a| a.status == AgentStatus::Running)
700            .count();
701        let completed = agents
702            .iter()
703            .filter(|a| a.status == AgentStatus::Completed)
704            .count();
705        let failed = agents
706            .iter()
707            .filter(|a| a.status == AgentStatus::Failed)
708            .count();
709        (starting, running, completed, failed)
710    }
711
712    /// Get the selected agent (if any)
713    pub fn selected_agent(&self) -> Option<&AgentState> {
714        let agents = self.agents();
715        if agents.is_empty() || self.selected >= agents.len() {
716            None
717        } else {
718            Some(&agents[self.selected])
719        }
720    }
721
722    // === Wave-related methods ===
723
724    /// Refresh waves data from phases
725    pub fn refresh_waves(&mut self) {
726        // Reload phases from storage
727        let storage = Storage::new(self.project_root.clone());
728        self.phases = storage.load_tasks().unwrap_or_default();
729
730        // In swarm mode, use actual wave data from swarm session
731        if self.swarm_mode {
732            self.waves = self.compute_swarm_waves();
733            // Update swarm progress after computing waves (now that phases are fresh)
734            self.swarm_progress = self.compute_swarm_progress();
735            return;
736        }
737
738        // Get running agent task IDs
739        let running_task_ids: HashSet<String> = self
740            .agents()
741            .iter()
742            .filter(|a| a.status == AgentStatus::Running || a.status == AgentStatus::Starting)
743            .map(|a| a.task_id.clone())
744            .collect();
745
746        // Determine which tag to use
747        let tag = self.active_tag.clone().or_else(|| {
748            // Try to get tag from session
749            self.session.as_ref().map(|s| s.tag.clone())
750        });
751
752        let Some(tag) = tag else {
753            self.waves = Vec::new();
754            return;
755        };
756
757        let Some(phase) = self.phases.get(&tag) else {
758            self.waves = Vec::new();
759            return;
760        };
761
762        // Build waves using topological sort
763        self.waves = self.compute_waves(phase, &running_task_ids);
764    }
765
766    /// Compute swarm progress from session data and phases
767    ///
768    /// This aggregates progress information from the swarm session and task phases
769    /// into a consolidated SwarmProgress struct for easy access by the UI.
770    fn compute_swarm_progress(&self) -> Option<SwarmProgress> {
771        let swarm = self.swarm_session_data.as_ref()?;
772        let phase = self.phases.get(&swarm.tag);
773
774        let total_waves = self.waves.len();
775
776        // Find current wave (first incomplete wave, or last wave if all complete)
777        let current_wave = swarm
778            .waves
779            .iter()
780            .find(|w| w.completed_at.is_none())
781            .map(|w| w.wave_number)
782            .unwrap_or_else(|| swarm.waves.last().map(|w| w.wave_number).unwrap_or(0));
783
784        // Count tasks by status from phase data
785        let (tasks_completed, tasks_in_progress, tasks_failed, tasks_total) =
786            if let Some(phase) = phase {
787                // Get all task IDs from swarm waves
788                let swarm_task_ids: HashSet<String> = swarm
789                    .waves
790                    .iter()
791                    .flat_map(|w| w.all_task_ids())
792                    .collect();
793
794                let mut completed = 0;
795                let mut in_progress = 0;
796                let mut failed = 0;
797                let total = swarm_task_ids.len();
798
799                for task_id in &swarm_task_ids {
800                    if let Some(task) = phase.get_task(task_id) {
801                        match task.status {
802                            TaskStatus::Done => completed += 1,
803                            TaskStatus::InProgress => in_progress += 1,
804                            TaskStatus::Blocked => failed += 1,
805                            _ => {}
806                        }
807                    }
808                }
809
810                (completed, in_progress, failed, total)
811            } else {
812                // Fall back to counting from swarm session
813                let total = swarm.total_tasks();
814                let failed = swarm.total_failures();
815                (0, 0, failed, total)
816            };
817
818        // Count validation results
819        let (waves_validated, waves_failed_validation) = swarm.waves.iter().fold(
820            (0, 0),
821            |(validated, failed), wave| match &wave.validation {
822                Some(v) if v.all_passed => (validated + 1, failed),
823                Some(_) => (validated, failed + 1),
824                None => (validated, failed),
825            },
826        );
827
828        // Count total repairs
829        let total_repairs: usize = swarm.waves.iter().map(|w| w.repairs.len()).sum();
830
831        Some(SwarmProgress {
832            current_wave,
833            total_waves,
834            tasks_completed,
835            tasks_total,
836            tasks_in_progress,
837            tasks_failed,
838            waves_validated,
839            waves_failed_validation,
840            total_repairs,
841        })
842    }
843
844    /// Compute waves from swarm session data (shows actual execution waves)
845    fn compute_swarm_waves(&self) -> Vec<Wave> {
846        let Some(ref swarm) = self.swarm_session_data else {
847            return Vec::new();
848        };
849
850        let tag = &swarm.tag;
851        let phase = self.phases.get(tag);
852
853        swarm
854            .waves
855            .iter()
856            .map(|wave_state| {
857                // Collect all task IDs from all rounds in this wave
858                let task_ids: Vec<String> = wave_state
859                    .rounds
860                    .iter()
861                    .flat_map(|round| round.task_ids.iter().cloned())
862                    .collect();
863
864                let tasks: Vec<WaveTask> = task_ids
865                    .iter()
866                    .map(|task_id| {
867                        // Try to get task info from phase
868                        let (title, complexity, dependencies, task_status) =
869                            if let Some(phase) = phase {
870                                if let Some(task) = phase.get_task(task_id) {
871                                    (
872                                        task.title.clone(),
873                                        task.complexity,
874                                        task.dependencies.clone(),
875                                        Some(task.status.clone()),
876                                    )
877                                } else {
878                                    (task_id.clone(), 1, vec![], None)
879                                }
880                            } else {
881                                (task_id.clone(), 1, vec![], None)
882                            };
883
884                        // Determine state based on task status and wave completion
885                        let state = match task_status {
886                            Some(TaskStatus::Done) => WaveTaskState::Done,
887                            Some(TaskStatus::InProgress) => WaveTaskState::Running,
888                            Some(TaskStatus::Blocked) => WaveTaskState::Blocked,
889                            Some(TaskStatus::Pending) => {
890                                if wave_state.completed_at.is_some() {
891                                    // Wave completed but task still pending = failed/blocked
892                                    WaveTaskState::Blocked
893                                } else {
894                                    WaveTaskState::Ready
895                                }
896                            }
897                            _ => WaveTaskState::Ready,
898                        };
899
900                        WaveTask {
901                            id: task_id.clone(),
902                            title,
903                            tag: tag.clone(),
904                            state,
905                            complexity,
906                            dependencies,
907                        }
908                    })
909                    .collect();
910
911                Wave {
912                    number: wave_state.wave_number,
913                    tasks,
914                }
915            })
916            .collect()
917    }
918
919    /// Compute execution waves for a phase
920    fn compute_waves(&self, phase: &Phase, running_task_ids: &HashSet<String>) -> Vec<Wave> {
921        // Collect actionable tasks
922        let mut actionable: Vec<&Task> = Vec::new();
923        for task in &phase.tasks {
924            if task.status == TaskStatus::Done
925                || task.status == TaskStatus::Expanded
926                || task.status == TaskStatus::Cancelled
927            {
928                continue;
929            }
930
931            // Skip parent tasks that have subtasks - only subtasks should be spawned
932            if !task.subtasks.is_empty() {
933                continue;
934            }
935
936            // If subtask, only include if parent is expanded
937            if let Some(ref parent_id) = task.parent_id {
938                let parent_expanded = phase
939                    .get_task(parent_id)
940                    .map(|p| p.is_expanded())
941                    .unwrap_or(false);
942                if !parent_expanded {
943                    continue;
944                }
945            }
946
947            actionable.push(task);
948        }
949
950        if actionable.is_empty() {
951            return Vec::new();
952        }
953
954        // Build dependency graph
955        let task_ids: HashSet<String> = actionable.iter().map(|t| t.id.clone()).collect();
956        let mut in_degree: HashMap<String, usize> = HashMap::new();
957        let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
958
959        for task in &actionable {
960            in_degree.entry(task.id.clone()).or_insert(0);
961
962            for dep in &task.dependencies {
963                if task_ids.contains(dep) {
964                    // Internal dependency - track in graph
965                    *in_degree.entry(task.id.clone()).or_insert(0) += 1;
966                    dependents
967                        .entry(dep.clone())
968                        .or_default()
969                        .push(task.id.clone());
970                } else {
971                    // External dependency - check if satisfied
972                    // If not satisfied (e.g., Expanded with incomplete subtasks), block this task
973                    if !self.is_dependency_satisfied(dep, phase) {
974                        // Mark as blocked by setting very high in_degree
975                        *in_degree.entry(task.id.clone()).or_insert(0) += 1000;
976                    }
977                }
978            }
979        }
980
981        // Kahn's algorithm with wave tracking
982        let mut waves: Vec<Wave> = Vec::new();
983        let mut remaining = in_degree.clone();
984        let mut wave_number = 1;
985
986        while !remaining.is_empty() {
987            let mut ready: Vec<String> = remaining
988                .iter()
989                .filter(|(_, &deg)| deg == 0)
990                .map(|(id, _)| id.clone())
991                .collect();
992
993            if ready.is_empty() {
994                break; // Circular dependency
995            }
996
997            // Sort for stable display order
998            ready.sort();
999
1000            // Build wave tasks with state
1001            let mut wave_tasks: Vec<WaveTask> = ready
1002                .iter()
1003                .filter_map(|task_id| {
1004                    actionable.iter().find(|t| &t.id == task_id).map(|task| {
1005                        let state = if task.status == TaskStatus::Done {
1006                            WaveTaskState::Done
1007                        } else if running_task_ids.contains(&task.id) {
1008                            WaveTaskState::Running
1009                        } else if task.status == TaskStatus::InProgress {
1010                            WaveTaskState::InProgress
1011                        } else if task.status == TaskStatus::Blocked {
1012                            WaveTaskState::Blocked
1013                        } else if self.is_task_ready(task, phase) {
1014                            WaveTaskState::Ready
1015                        } else {
1016                            WaveTaskState::Blocked
1017                        };
1018
1019                        WaveTask {
1020                            id: task.id.clone(),
1021                            title: task.title.clone(),
1022                            tag: self.active_tag.clone().unwrap_or_default(),
1023                            state,
1024                            complexity: task.complexity,
1025                            dependencies: task.dependencies.clone(),
1026                        }
1027                    })
1028                })
1029                .collect();
1030
1031            // Remove ready tasks and update dependents
1032            for task_id in &ready {
1033                remaining.remove(task_id);
1034                if let Some(deps) = dependents.get(task_id) {
1035                    for dep_id in deps {
1036                        if let Some(deg) = remaining.get_mut(dep_id) {
1037                            *deg = deg.saturating_sub(1);
1038                        }
1039                    }
1040                }
1041            }
1042
1043            if !wave_tasks.is_empty() {
1044                // Sort tasks by ID for stable display
1045                wave_tasks.sort_by(|a, b| a.id.cmp(&b.id));
1046                waves.push(Wave {
1047                    number: wave_number,
1048                    tasks: wave_tasks,
1049                });
1050            }
1051            wave_number += 1;
1052        }
1053
1054        waves
1055    }
1056
1057    /// Check if a task is ready (dependencies met, pending)
1058    fn is_task_ready(&self, task: &Task, phase: &Phase) -> bool {
1059        if task.status != TaskStatus::Pending {
1060            return false;
1061        }
1062
1063        // Check all dependencies are satisfied
1064        for dep_id in &task.dependencies {
1065            if !self.is_dependency_satisfied(dep_id, phase) {
1066                return false;
1067            }
1068        }
1069
1070        true
1071    }
1072
1073    /// Check if a dependency is satisfied (Done, or Expanded with all subtasks done)
1074    fn is_dependency_satisfied(&self, dep_id: &str, phase: &Phase) -> bool {
1075        let Some(dep) = phase.get_task(dep_id) else {
1076            return true; // If dep not found, assume external/done
1077        };
1078
1079        match dep.status {
1080            TaskStatus::Done => true,
1081            TaskStatus::Expanded => {
1082                // Expanded task is only satisfied if all its subtasks are done
1083                if dep.subtasks.is_empty() {
1084                    false // Expanded with no subtasks = not done yet
1085                } else {
1086                    dep.subtasks.iter().all(|subtask_id| {
1087                        phase
1088                            .get_task(subtask_id)
1089                            .map(|st| st.status == TaskStatus::Done)
1090                            .unwrap_or(false)
1091                    })
1092                }
1093            }
1094            _ => false, // Pending, InProgress, Blocked, Cancelled = not satisfied
1095        }
1096    }
1097
1098    /// Get flat list of all tasks in waves for navigation
1099    pub fn all_wave_tasks(&self) -> Vec<&WaveTask> {
1100        self.waves.iter().flat_map(|w| w.tasks.iter()).collect()
1101    }
1102
1103    /// Get currently selected wave task
1104    pub fn selected_wave_task(&self) -> Option<&WaveTask> {
1105        let all_tasks = self.all_wave_tasks();
1106        all_tasks.get(self.wave_task_index).copied()
1107    }
1108
1109    // === Panel navigation ===
1110
1111    /// Switch focus to next panel
1112    pub fn next_panel(&mut self) {
1113        self.focused_panel = match self.focused_panel {
1114            FocusedPanel::Waves => FocusedPanel::Agents,
1115            FocusedPanel::Agents => FocusedPanel::Output,
1116            FocusedPanel::Output => FocusedPanel::Waves,
1117        };
1118    }
1119
1120    /// Switch focus to previous panel
1121    pub fn previous_panel(&mut self) {
1122        self.focused_panel = match self.focused_panel {
1123            FocusedPanel::Waves => FocusedPanel::Output,
1124            FocusedPanel::Agents => FocusedPanel::Waves,
1125            FocusedPanel::Output => FocusedPanel::Agents,
1126        };
1127    }
1128
1129    /// Move selection up in current panel
1130    pub fn move_up(&mut self) {
1131        match self.focused_panel {
1132            FocusedPanel::Waves => {
1133                if self.wave_task_index > 0 {
1134                    self.wave_task_index -= 1;
1135                    self.adjust_wave_scroll();
1136                }
1137            }
1138            FocusedPanel::Agents => self.previous_agent(),
1139            FocusedPanel::Output => self.scroll_up(1),
1140        }
1141    }
1142
1143    /// Move selection down in current panel
1144    pub fn move_down(&mut self) {
1145        match self.focused_panel {
1146            FocusedPanel::Waves => {
1147                let max = self.all_wave_tasks().len().saturating_sub(1);
1148                if self.wave_task_index < max {
1149                    self.wave_task_index += 1;
1150                    self.adjust_wave_scroll();
1151                }
1152            }
1153            FocusedPanel::Agents => self.next_agent(),
1154            FocusedPanel::Output => self.scroll_down(1),
1155        }
1156    }
1157
1158    /// Adjust wave scroll offset to keep selected item visible
1159    /// Assumes visible height of ~4 items (plus wave headers)
1160    fn adjust_wave_scroll(&mut self) {
1161        // Calculate the line index of the current task
1162        // Each wave has 1 header line, then tasks
1163        let mut line_idx = 0;
1164        let mut found = false;
1165        let mut task_counter = 0;
1166
1167        for wave in &self.waves {
1168            line_idx += 1; // wave header
1169            for _ in &wave.tasks {
1170                if task_counter == self.wave_task_index {
1171                    found = true;
1172                    break;
1173                }
1174                line_idx += 1;
1175                task_counter += 1;
1176            }
1177            if found {
1178                break;
1179            }
1180        }
1181
1182        // Visible height is approximately 4-5 lines in the waves panel
1183        let visible_height = 4;
1184
1185        // Scroll to keep current line visible
1186        if line_idx < self.wave_scroll_offset {
1187            self.wave_scroll_offset = line_idx;
1188        } else if line_idx >= self.wave_scroll_offset + visible_height {
1189            self.wave_scroll_offset = line_idx.saturating_sub(visible_height - 1);
1190        }
1191    }
1192
1193    // === Task selection for spawning ===
1194
1195    /// Toggle selection of currently highlighted task
1196    pub fn toggle_task_selection(&mut self) {
1197        if let Some(task) = self.selected_wave_task() {
1198            let task_id = task.id.clone();
1199            if self.selected_tasks.contains(&task_id) {
1200                self.selected_tasks.remove(&task_id);
1201            } else {
1202                // Only allow selecting ready tasks
1203                if task.state == WaveTaskState::Ready {
1204                    self.selected_tasks.insert(task_id);
1205                }
1206            }
1207        }
1208    }
1209
1210    /// Select all ready tasks in waves
1211    pub fn select_all_ready(&mut self) {
1212        for wave in &self.waves {
1213            for task in &wave.tasks {
1214                if task.state == WaveTaskState::Ready {
1215                    self.selected_tasks.insert(task.id.clone());
1216                }
1217            }
1218        }
1219    }
1220
1221    /// Clear all task selections
1222    pub fn clear_selection(&mut self) {
1223        self.selected_tasks.clear();
1224    }
1225
1226    /// Get count of ready tasks
1227    pub fn ready_task_count(&self) -> usize {
1228        self.waves
1229            .iter()
1230            .flat_map(|w| &w.tasks)
1231            .filter(|t| t.state == WaveTaskState::Ready)
1232            .count()
1233    }
1234
1235    /// Get count of selected tasks
1236    pub fn selected_task_count(&self) -> usize {
1237        self.selected_tasks.len()
1238    }
1239
1240    /// Get selected tasks for spawning
1241    pub fn get_selected_tasks(&self) -> Vec<&WaveTask> {
1242        self.all_wave_tasks()
1243            .into_iter()
1244            .filter(|t| self.selected_tasks.contains(&t.id))
1245            .collect()
1246    }
1247
1248    /// Spawn the selected tasks
1249    /// Returns the number of tasks successfully spawned
1250    pub fn spawn_selected_tasks(&mut self) -> Result<usize> {
1251        use crate::commands::spawn::{agent, terminal};
1252
1253        let tasks_to_spawn: Vec<(String, String, String)> = self
1254            .get_selected_tasks()
1255            .iter()
1256            .map(|t| (t.id.clone(), t.title.clone(), t.tag.clone()))
1257            .collect();
1258
1259        if tasks_to_spawn.is_empty() {
1260            return Ok(0);
1261        }
1262
1263        // Get working directory
1264        let working_dir = self
1265            .project_root
1266            .clone()
1267            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1268
1269        // Get session info
1270        let session = match &self.session {
1271            Some(s) => s,
1272            None => {
1273                self.error = Some("No session loaded".to_string());
1274                return Ok(0);
1275            }
1276        };
1277
1278        let session_name = session.session_name.clone();
1279        let mut spawned_count = 0;
1280
1281        // Load phase to get full task data
1282        let storage = Storage::new(self.project_root.clone());
1283
1284        for (task_id, task_title, tag) in &tasks_to_spawn {
1285            // Get full task from phase
1286            let phase = match self.phases.get(tag) {
1287                Some(p) => p,
1288                None => continue,
1289            };
1290
1291            let task = match phase.get_task(task_id) {
1292                Some(t) => t,
1293                None => continue,
1294            };
1295
1296            // Generate prompt
1297            let prompt = agent::generate_prompt(task, tag);
1298
1299            // Spawn in tmux (always use tmux from TUI since we're in a session)
1300            match terminal::spawn_terminal(task_id, &prompt, &working_dir, &session_name) {
1301                Ok(_window_index) => {
1302                    spawned_count += 1;
1303
1304                    // Add to session agents
1305                    if let Some(ref mut session) = self.session {
1306                        session.add_agent(task_id, task_title, tag);
1307                    }
1308
1309                    // Mark task as in-progress
1310                    if let Ok(mut phase) = storage.load_group(tag) {
1311                        if let Some(task) = phase.get_task_mut(task_id) {
1312                            task.set_status(TaskStatus::InProgress);
1313                            let _ = storage.update_group(tag, &phase);
1314                        }
1315                    }
1316                }
1317                Err(e) => {
1318                    self.error = Some(format!("Failed to spawn {}: {}", task_id, e));
1319                }
1320            }
1321
1322            // Small delay between spawns
1323            if spawned_count < tasks_to_spawn.len() {
1324                std::thread::sleep(Duration::from_millis(300));
1325            }
1326        }
1327
1328        // Save updated session
1329        if spawned_count > 0 {
1330            if let Some(ref session) = self.session {
1331                let _ = crate::commands::spawn::monitor::save_session(
1332                    self.project_root.as_ref(),
1333                    session,
1334                );
1335            }
1336
1337            // Clear selection and refresh
1338            self.selected_tasks.clear();
1339            self.refresh()?;
1340            self.refresh_waves();
1341        }
1342
1343        Ok(spawned_count)
1344    }
1345
1346    /// Prepare to start swarm - returns swarm command and tag
1347    pub fn prepare_swarm_start(&self) -> Option<(String, String)> {
1348        // Get tag from session or active tag
1349        let tag = self
1350            .session
1351            .as_ref()
1352            .map(|s| s.tag.clone())
1353            .or_else(|| self.active_tag.clone())?;
1354
1355        // Build swarm command
1356        let session_base = self.session_name.replace("swarm-", "").replace("scud-", "");
1357        let cmd = format!("scud swarm --tag {} --session {}", tag, session_base);
1358
1359        Some((cmd, tag))
1360    }
1361
1362    /// Update the status of the currently selected agent's task
1363    pub fn set_selected_task_status(&mut self, new_status: TaskStatus) -> Result<()> {
1364        let Some(ref session) = self.session else {
1365            self.error = Some("No session loaded".to_string());
1366            return Ok(());
1367        };
1368
1369        let agents = session.agents.clone();
1370        if agents.is_empty() || self.selected >= agents.len() {
1371            self.error = Some("No agent selected".to_string());
1372            return Ok(());
1373        }
1374
1375        let agent = &agents[self.selected];
1376        let task_id = &agent.task_id;
1377        let tag = &agent.tag;
1378
1379        // Update task status in storage
1380        let storage = Storage::new(self.project_root.clone());
1381        if let Ok(mut phase) = storage.load_group(tag) {
1382            if let Some(task) = phase.get_task_mut(task_id) {
1383                task.set_status(new_status.clone());
1384                if let Err(e) = storage.update_group(tag, &phase) {
1385                    self.error = Some(format!("Failed to save: {}", e));
1386                    return Ok(());
1387                }
1388                // Show confirmation
1389                self.error = Some(format!("✓ {} → {}", task_id, new_status.as_str()));
1390            } else {
1391                self.error = Some(format!("Task {} not found", task_id));
1392            }
1393        } else {
1394            self.error = Some(format!("Failed to load phase {}", tag));
1395        }
1396
1397        // Refresh to show updated status
1398        self.refresh()?;
1399        self.refresh_waves();
1400
1401        Ok(())
1402    }
1403
1404    /// Spawn a single task with Ralph loop enabled
1405    /// The agent will keep trying until the task is marked done
1406    fn spawn_task_with_ralph(&mut self, task_id: &str) -> Result<()> {
1407        use crate::commands::spawn::{agent, terminal};
1408
1409        // Find the task in waves
1410        let task_info = self
1411            .waves
1412            .iter()
1413            .flat_map(|w| w.tasks.iter())
1414            .find(|t| t.id == task_id)
1415            .map(|t| (t.id.clone(), t.title.clone(), t.tag.clone()));
1416
1417        let (task_id, task_title, tag) = match task_info {
1418            Some(info) => info,
1419            None => return Ok(()),
1420        };
1421
1422        // Get working directory
1423        let working_dir = self
1424            .project_root
1425            .clone()
1426            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1427
1428        // Get session info
1429        let session = match &self.session {
1430            Some(s) => s,
1431            None => {
1432                self.error = Some("No session loaded".to_string());
1433                return Ok(());
1434            }
1435        };
1436
1437        let session_name = session.session_name.clone();
1438
1439        // Load phase to get full task data
1440        let storage = Storage::new(self.project_root.clone());
1441
1442        let phase = match self.phases.get(&tag) {
1443            Some(p) => p,
1444            None => return Ok(()),
1445        };
1446
1447        let task = match phase.get_task(&task_id) {
1448            Some(t) => t,
1449            None => return Ok(()),
1450        };
1451
1452        // Generate prompt with Ralph loop instructions
1453        let base_prompt = agent::generate_prompt(task, &tag);
1454        let ralph_prompt = format!(
1455            r#"{}
1456
1457═══════════════════════════════════════════════════════════
1458RALPH LOOP MODE - Autonomous Task Completion
1459═══════════════════════════════════════════════════════════
1460
1461CRITICAL: Your task ID is **{task_id}** (NOT any parent task!)
1462
1463You are in a Ralph loop. Keep working until the task is COMPLETE.
1464
1465After EACH attempt:
14661. Run EXACTLY: scud set-status {task_id} done
1467   ⚠️  Use task ID "{task_id}" - do NOT use any other task ID!
14682. Verify the task is truly done (tests pass, code works)
14693. If something failed, fix it and try again
1470
1471The loop will continue until task {task_id} is marked done.
1472Do NOT give up. Keep iterating until success.
1473
1474When you have genuinely completed task {task_id}, output:
1475<promise>TASK {task_id} COMPLETE</promise>
1476
1477DO NOT output this promise unless task {task_id} is TRULY complete!
1478═══════════════════════════════════════════════════════════
1479"#,
1480            base_prompt,
1481            task_id = task_id
1482        );
1483
1484        // Spawn in tmux with Ralph loop wrapper
1485        match terminal::spawn_terminal_ralph(
1486            &task_id,
1487            &ralph_prompt,
1488            &working_dir,
1489            &session_name,
1490            &format!("TASK {} COMPLETE", task_id),
1491        ) {
1492            Ok(()) => {
1493                // Add to session agents
1494                if let Some(ref mut session) = self.session {
1495                    session.add_agent(&task_id, &task_title, &tag);
1496                }
1497
1498                // Mark task as in-progress
1499                if let Ok(mut phase) = storage.load_group(&tag) {
1500                    if let Some(task) = phase.get_task_mut(&task_id) {
1501                        task.set_status(TaskStatus::InProgress);
1502                        let _ = storage.update_group(&tag, &phase);
1503                    }
1504                }
1505
1506                // Save session
1507                if let Some(ref session) = self.session {
1508                    let _ = crate::commands::spawn::monitor::save_session(
1509                        self.project_root.as_ref(),
1510                        session,
1511                    );
1512                }
1513
1514                // Refresh
1515                let _ = self.refresh();
1516                self.refresh_waves();
1517            }
1518            Err(e) => {
1519                self.error = Some(format!(
1520                    "Failed to spawn Ralph agent for {}: {}",
1521                    task_id, e
1522                ));
1523            }
1524        }
1525
1526        Ok(())
1527    }
1528}