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