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