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(
1078                &terminal::Terminal::Tmux,
1079                task_id,
1080                &prompt,
1081                &working_dir,
1082                &session_name,
1083            ) {
1084                Ok(()) => {
1085                    spawned_count += 1;
1086
1087                    // Add to session agents
1088                    if let Some(ref mut session) = self.session {
1089                        session.add_agent(task_id, task_title, tag);
1090                    }
1091
1092                    // Mark task as in-progress
1093                    if let Ok(mut phase) = storage.load_group(tag) {
1094                        if let Some(task) = phase.get_task_mut(task_id) {
1095                            task.set_status(TaskStatus::InProgress);
1096                            let _ = storage.update_group(tag, &phase);
1097                        }
1098                    }
1099                }
1100                Err(e) => {
1101                    self.error = Some(format!("Failed to spawn {}: {}", task_id, e));
1102                }
1103            }
1104
1105            // Small delay between spawns
1106            if spawned_count < tasks_to_spawn.len() {
1107                std::thread::sleep(Duration::from_millis(300));
1108            }
1109        }
1110
1111        // Save updated session
1112        if spawned_count > 0 {
1113            if let Some(ref session) = self.session {
1114                let _ = crate::commands::spawn::monitor::save_session(
1115                    self.project_root.as_ref(),
1116                    session,
1117                );
1118            }
1119
1120            // Clear selection and refresh
1121            self.selected_tasks.clear();
1122            self.refresh()?;
1123            self.refresh_waves();
1124        }
1125
1126        Ok(spawned_count)
1127    }
1128
1129    /// Spawn a single task with Ralph loop enabled
1130    /// The agent will keep trying until the task is marked done
1131    fn spawn_task_with_ralph(&mut self, task_id: &str) -> Result<()> {
1132        use crate::commands::spawn::{agent, terminal};
1133
1134        // Find the task in waves
1135        let task_info = self
1136            .waves
1137            .iter()
1138            .flat_map(|w| w.tasks.iter())
1139            .find(|t| t.id == task_id)
1140            .map(|t| (t.id.clone(), t.title.clone(), t.tag.clone()));
1141
1142        let (task_id, task_title, tag) = match task_info {
1143            Some(info) => info,
1144            None => return Ok(()),
1145        };
1146
1147        // Get working directory
1148        let working_dir = self
1149            .project_root
1150            .clone()
1151            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1152
1153        // Get session info
1154        let session = match &self.session {
1155            Some(s) => s,
1156            None => {
1157                self.error = Some("No session loaded".to_string());
1158                return Ok(());
1159            }
1160        };
1161
1162        let session_name = session.session_name.clone();
1163
1164        // Load phase to get full task data
1165        let storage = Storage::new(self.project_root.clone());
1166
1167        let phase = match self.phases.get(&tag) {
1168            Some(p) => p,
1169            None => return Ok(()),
1170        };
1171
1172        let task = match phase.get_task(&task_id) {
1173            Some(t) => t,
1174            None => return Ok(()),
1175        };
1176
1177        // Generate prompt with Ralph loop instructions
1178        let base_prompt = agent::generate_prompt(task, &tag);
1179        let ralph_prompt = format!(
1180            r#"{}
1181
1182═══════════════════════════════════════════════════════════
1183RALPH LOOP MODE - Autonomous Task Completion
1184═══════════════════════════════════════════════════════════
1185
1186CRITICAL: Your task ID is **{task_id}** (NOT any parent task!)
1187
1188You are in a Ralph loop. Keep working until the task is COMPLETE.
1189
1190After EACH attempt:
11911. Run EXACTLY: scud set-status {task_id} done
1192   ⚠️  Use task ID "{task_id}" - do NOT use any other task ID!
11932. Verify the task is truly done (tests pass, code works)
11943. If something failed, fix it and try again
1195
1196The loop will continue until task {task_id} is marked done.
1197Do NOT give up. Keep iterating until success.
1198
1199When you have genuinely completed task {task_id}, output:
1200<promise>TASK {task_id} COMPLETE</promise>
1201
1202DO NOT output this promise unless task {task_id} is TRULY complete!
1203═══════════════════════════════════════════════════════════
1204"#,
1205            base_prompt,
1206            task_id = task_id
1207        );
1208
1209        // Spawn in tmux with Ralph loop wrapper
1210        match terminal::spawn_terminal_ralph(
1211            &terminal::Terminal::Tmux,
1212            &task_id,
1213            &ralph_prompt,
1214            &working_dir,
1215            &session_name,
1216            &format!("TASK {} COMPLETE", task_id),
1217        ) {
1218            Ok(()) => {
1219                // Add to session agents
1220                if let Some(ref mut session) = self.session {
1221                    session.add_agent(&task_id, &task_title, &tag);
1222                }
1223
1224                // Mark task as in-progress
1225                if let Ok(mut phase) = storage.load_group(&tag) {
1226                    if let Some(task) = phase.get_task_mut(&task_id) {
1227                        task.set_status(TaskStatus::InProgress);
1228                        let _ = storage.update_group(&tag, &phase);
1229                    }
1230                }
1231
1232                // Save session
1233                if let Some(ref session) = self.session {
1234                    let _ = crate::commands::spawn::monitor::save_session(
1235                        self.project_root.as_ref(),
1236                        session,
1237                    );
1238                }
1239
1240                // Refresh
1241                let _ = self.refresh();
1242                self.refresh_waves();
1243            }
1244            Err(e) => {
1245                self.error = Some(format!(
1246                    "Failed to spawn Ralph agent for {}: {}",
1247                    task_id, e
1248                ));
1249            }
1250        }
1251
1252        Ok(())
1253    }
1254}