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