1use anyhow::Result;
11use std::collections::{HashMap, HashSet};
12use std::path::PathBuf;
13use std::process::Command;
14use std::time::{Duration, Instant};
15
16use crate::commands::spawn::monitor::{
17 load_session, save_session, AgentState, AgentStatus, SpawnSession,
18};
19use crate::commands::swarm::session as swarm_session;
20use crate::models::phase::Phase;
21use crate::models::task::{Task, TaskStatus};
22use crate::storage::Storage;
23
24#[derive(Debug, Clone, PartialEq)]
26pub enum ViewMode {
27 Split,
29 Fullscreen,
31 Input,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq)]
37pub enum FocusedPanel {
38 Waves,
39 Agents,
40 Output,
41}
42
43#[derive(Debug, Clone, PartialEq)]
45pub enum WaveTaskState {
46 Ready,
48 Running,
50 Done,
52 Blocked,
54 InProgress,
56}
57
58#[derive(Debug, Clone)]
60pub struct WaveTask {
61 pub id: String,
62 pub title: String,
63 pub tag: String,
64 pub state: WaveTaskState,
65 pub complexity: u32,
66 pub dependencies: Vec<String>,
67}
68
69#[derive(Debug, Clone)]
71pub struct Wave {
72 pub number: usize,
73 pub tasks: Vec<WaveTask>,
74}
75
76#[derive(Debug, Clone, Default)]
83pub struct SwarmProgress {
84 pub current_wave: usize,
86 pub total_waves: usize,
88 pub tasks_completed: usize,
90 pub tasks_total: usize,
92 pub tasks_in_progress: usize,
94 pub tasks_failed: usize,
96 pub waves_validated: usize,
98 pub waves_failed_validation: usize,
100 pub total_repairs: usize,
102}
103
104pub struct App {
106 pub project_root: Option<PathBuf>,
108 pub session_name: String,
110 pub session: Option<SpawnSession>,
112 pub selected: usize,
114 pub view_mode: ViewMode,
116 pub show_help: bool,
118 last_refresh: Instant,
120 refresh_interval: Duration,
122 pub error: Option<String>,
124 pub live_output: Vec<String>,
126 last_output_refresh: Instant,
128 output_refresh_interval: Duration,
130 pub input_buffer: String,
132 pub scroll_offset: usize,
134 pub auto_scroll: bool,
136
137 pub focused_panel: FocusedPanel,
140 pub waves: Vec<Wave>,
142 pub selected_tasks: HashSet<String>,
144 pub wave_task_index: usize,
146 pub wave_scroll_offset: usize,
148 pub agents_scroll_offset: usize,
150 pub active_tag: Option<String>,
152 phases: HashMap<String, Phase>,
154
155 pub ralph_mode: bool,
158 pub ralph_max_parallel: usize,
160 last_ralph_check: Instant,
162
163 pub swarm_mode: bool,
166 pub swarm_session_data: Option<swarm_session::SwarmSession>,
168 pub swarm_progress: Option<SwarmProgress>,
170}
171
172impl App {
173 pub fn new(
175 project_root: Option<PathBuf>,
176 session_name: &str,
177 swarm_mode: bool,
178 ) -> Result<Self> {
179 let storage = Storage::new(project_root.clone());
181 let active_tag = storage.get_active_group().ok().flatten();
182 let phases = storage.load_tasks().unwrap_or_default();
183
184 let mut app = Self {
185 project_root,
186 session_name: session_name.to_string(),
187 session: None,
188 selected: 0,
189 view_mode: ViewMode::Split,
190 show_help: false,
191 last_refresh: Instant::now(),
192 refresh_interval: Duration::from_secs(2),
193 error: None,
194 live_output: Vec::new(),
195 last_output_refresh: Instant::now(),
196 output_refresh_interval: Duration::from_millis(500),
197 input_buffer: String::new(),
198 scroll_offset: 0,
199 auto_scroll: true,
200 focused_panel: FocusedPanel::Waves,
202 waves: Vec::new(),
203 selected_tasks: HashSet::new(),
204 wave_task_index: 0,
205 wave_scroll_offset: 0,
206 agents_scroll_offset: 0,
207 active_tag,
208 phases,
209 ralph_mode: false,
211 ralph_max_parallel: 5,
212 last_ralph_check: Instant::now(),
213 swarm_mode,
215 swarm_session_data: None,
216 swarm_progress: None,
217 };
218 app.refresh()?;
219 app.refresh_waves();
220 app.refresh_live_output();
221 Ok(app)
222 }
223
224 pub fn refresh(&mut self) -> Result<()> {
226 if self.swarm_mode {
227 match swarm_session::load_session(self.project_root.as_ref(), &self.session_name) {
229 Ok(session) => {
230 self.swarm_session_data = Some(session);
231 self.error = None;
232 self.swarm_progress = self.compute_swarm_progress();
234 }
235 Err(e) => {
236 self.error = Some(format!("Failed to load swarm session: {}", e));
237 self.swarm_progress = None;
238 }
239 }
240 } else {
241 match load_session(self.project_root.as_ref(), &self.session_name) {
243 Ok(mut session) => {
244 self.refresh_agent_statuses(&mut session);
246
247 let _ = save_session(self.project_root.as_ref(), &session);
249
250 self.session = Some(session);
251 self.error = None;
252 }
253 Err(e) => {
254 self.error = Some(format!("Failed to load session: {}", e));
255 }
256 }
257 }
258 self.last_refresh = Instant::now();
259 Ok(())
260 }
261
262 pub fn refresh_live_output(&mut self) {
264 let agents = self.agents();
265 if agents.is_empty() || self.selected >= agents.len() {
266 self.live_output = vec!["No agent selected".to_string()];
267 return;
268 }
269
270 let agent = &agents[self.selected];
271 let session = match &self.session {
272 Some(s) => s,
273 None => {
274 self.live_output = vec!["No session loaded".to_string()];
275 return;
276 }
277 };
278
279 let tmux_windows = self.get_tmux_windows(&session.session_name);
281 let matching_window = tmux_windows.iter().find(|(_, name)| {
282 name.starts_with(&agent.window_name) || agent.window_name.starts_with(name)
283 });
284
285 let window_target = match matching_window {
286 Some((index, _)) => format!("{}:{}", session.session_name, index),
287 None => {
288 self.live_output = vec![format!("Window '{}' not found", agent.window_name)];
289 return;
290 }
291 };
292
293 let output = Command::new("tmux")
295 .args([
296 "capture-pane",
297 "-t",
298 &window_target,
299 "-p", "-S",
301 "-100", ])
303 .output();
304
305 match output {
306 Ok(out) if out.status.success() => {
307 let content = String::from_utf8_lossy(&out.stdout);
308 self.live_output = content.lines().map(|s| s.to_string()).collect();
309
310 while self
312 .live_output
313 .last()
314 .map(|s| s.trim().is_empty())
315 .unwrap_or(false)
316 {
317 self.live_output.pop();
318 }
319 }
320 Ok(out) => {
321 self.live_output = vec![format!("Error: {}", String::from_utf8_lossy(&out.stderr))];
322 }
323 Err(e) => {
324 self.live_output = vec![format!("tmux error: {}", e)];
325 }
326 }
327
328 self.last_output_refresh = Instant::now();
329 }
330
331 fn refresh_agent_statuses(&self, session: &mut SpawnSession) {
333 let tmux_windows = self.get_tmux_windows(&session.session_name);
334 let storage = Storage::new(self.project_root.clone());
335 let all_phases = storage.load_tasks().ok();
336
337 for agent in &mut session.agents {
338 let window_exists = tmux_windows.iter().any(|(_, name)| {
339 name.starts_with(&agent.window_name) || agent.window_name.starts_with(name)
340 });
341
342 let task_status = all_phases.as_ref().and_then(|phases| {
343 phases.get(&agent.tag).and_then(|phase| {
344 phase
345 .get_task(&agent.task_id)
346 .map(|task| task.status.clone())
347 })
348 });
349
350 agent.status = match (&task_status, window_exists) {
351 (Some(TaskStatus::Done), _) => AgentStatus::Completed,
352 (Some(TaskStatus::Blocked), _) => AgentStatus::Failed,
353 (Some(TaskStatus::InProgress), true) => AgentStatus::Running,
354 (Some(TaskStatus::InProgress), false) => AgentStatus::Completed,
355 (_, false) => AgentStatus::Completed,
356 (_, true) => AgentStatus::Running,
357 };
358 }
359 }
360
361 fn get_tmux_windows(&self, session_name: &str) -> Vec<(usize, String)> {
363 let output = Command::new("tmux")
364 .args([
365 "list-windows",
366 "-t",
367 session_name,
368 "-F",
369 "#{window_index}:#{window_name}",
370 ])
371 .output();
372
373 match output {
374 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
375 .lines()
376 .filter_map(|line| {
377 let parts: Vec<&str> = line.splitn(2, ':').collect();
378 if parts.len() == 2 {
379 parts[0].parse().ok().map(|idx| (idx, parts[1].to_string()))
380 } else {
381 None
382 }
383 })
384 .collect(),
385 _ => Vec::new(),
386 }
387 }
388
389 pub fn tick(&mut self) -> Result<()> {
391 if self.last_refresh.elapsed() >= self.refresh_interval {
393 self.refresh()?;
394 self.refresh_waves();
395 }
396
397 if self.last_output_refresh.elapsed() >= self.output_refresh_interval {
399 self.refresh_live_output();
400 }
401
402 if self.ralph_mode && self.last_ralph_check.elapsed() >= Duration::from_secs(5) {
404 self.ralph_auto_spawn();
405 self.last_ralph_check = Instant::now();
406 }
407
408 Ok(())
409 }
410
411 pub fn toggle_ralph_mode(&mut self) {
413 self.ralph_mode = !self.ralph_mode;
414 if self.ralph_mode {
415 self.ralph_auto_spawn();
417 }
418 }
419
420 fn ralph_auto_spawn(&mut self) {
422 let running_count = self
424 .agents()
425 .iter()
426 .filter(|a| a.status == AgentStatus::Running || a.status == AgentStatus::Starting)
427 .count();
428
429 if running_count >= self.ralph_max_parallel {
430 return; }
432
433 let slots_available = self.ralph_max_parallel - running_count;
435 let mut tasks_to_spawn: Vec<String> = Vec::new();
436
437 for wave in &self.waves {
438 for task in &wave.tasks {
439 if task.state == WaveTaskState::Ready && !self.selected_tasks.contains(&task.id) {
440 let already_spawned = self.agents().iter().any(|a| a.task_id == task.id);
442 if !already_spawned {
443 tasks_to_spawn.push(task.id.clone());
444 if tasks_to_spawn.len() >= slots_available {
445 break;
446 }
447 }
448 }
449 }
450 if tasks_to_spawn.len() >= slots_available {
451 break;
452 }
453 }
454
455 for task_id in tasks_to_spawn {
457 let _ = self.spawn_task_with_ralph(&task_id);
458 }
459 }
460
461 pub fn agents(&self) -> &[AgentState] {
463 self.session
464 .as_ref()
465 .map(|s| s.agents.as_slice())
466 .unwrap_or(&[])
467 }
468
469 pub fn next_agent(&mut self) {
471 let len = self.agents().len();
472 if len > 0 {
473 self.selected = (self.selected + 1) % len;
474 self.adjust_agents_scroll();
475 self.reset_scroll();
476 self.refresh_live_output();
477 }
478 }
479
480 pub fn previous_agent(&mut self) {
482 let len = self.agents().len();
483 if len > 0 {
484 self.selected = if self.selected > 0 {
485 self.selected - 1
486 } else {
487 len - 1
488 };
489 self.adjust_agents_scroll();
490 self.reset_scroll();
491 self.refresh_live_output();
492 }
493 }
494
495 pub fn adjust_agents_scroll(&mut self) {
498 const VISIBLE_LINES: usize = 8;
499
500 if self.selected < self.agents_scroll_offset {
502 self.agents_scroll_offset = self.selected;
503 }
504 else if self.selected >= self.agents_scroll_offset + VISIBLE_LINES {
506 self.agents_scroll_offset = self.selected.saturating_sub(VISIBLE_LINES - 1);
507 }
508 }
509
510 pub fn toggle_fullscreen(&mut self) {
512 self.view_mode = match self.view_mode {
513 ViewMode::Split => ViewMode::Fullscreen,
514 ViewMode::Fullscreen => ViewMode::Split,
515 ViewMode::Input => ViewMode::Fullscreen,
516 };
517 }
518
519 pub fn exit_fullscreen(&mut self) {
521 self.view_mode = ViewMode::Split;
522 self.input_buffer.clear();
523 }
524
525 pub fn enter_input_mode(&mut self) {
527 self.view_mode = ViewMode::Input;
528 self.input_buffer.clear();
529 }
530
531 pub fn input_char(&mut self, c: char) {
533 self.input_buffer.push(c);
534 }
535
536 pub fn input_backspace(&mut self) {
538 self.input_buffer.pop();
539 }
540
541 pub fn send_input(&mut self) -> Result<()> {
543 if self.input_buffer.is_empty() {
544 return Ok(());
545 }
546
547 let session = match &self.session {
548 Some(s) => s,
549 None => {
550 self.error = Some("No session loaded".to_string());
551 return Ok(());
552 }
553 };
554
555 let agents = self.agents();
556 if agents.is_empty() || self.selected >= agents.len() {
557 self.error = Some("No agent selected".to_string());
558 return Ok(());
559 }
560
561 let agent = &agents[self.selected];
562
563 let tmux_windows = self.get_tmux_windows(&session.session_name);
565 let matching_window = tmux_windows.iter().find(|(_, name)| {
566 name.starts_with(&agent.window_name) || agent.window_name.starts_with(name)
567 });
568
569 let window_target = match matching_window {
570 Some((index, _)) => format!("{}:{}", session.session_name, index),
571 None => {
572 self.error = Some(format!("Window not found for {}", agent.task_id));
573 return Ok(());
574 }
575 };
576
577 let result = Command::new("tmux")
579 .args([
580 "send-keys",
581 "-t",
582 &window_target,
583 &self.input_buffer,
584 "Enter",
585 ])
586 .output();
587
588 match result {
589 Ok(out) if out.status.success() => {
590 self.error = None;
591 self.input_buffer.clear();
592 self.view_mode = ViewMode::Fullscreen; self.refresh_live_output();
594 }
595 Ok(out) => {
596 self.error = Some(format!(
597 "Send failed: {}",
598 String::from_utf8_lossy(&out.stderr)
599 ));
600 }
601 Err(e) => {
602 self.error = Some(format!("tmux error: {}", e));
603 }
604 }
605
606 Ok(())
607 }
608
609 pub fn restart_agent(&mut self) -> Result<()> {
611 let session = match &self.session {
612 Some(s) => s,
613 None => return Ok(()),
614 };
615
616 let agents = self.agents();
617 if agents.is_empty() || self.selected >= agents.len() {
618 return Ok(());
619 }
620
621 let agent = &agents[self.selected];
622
623 let tmux_windows = self.get_tmux_windows(&session.session_name);
625 let matching_window = tmux_windows.iter().find(|(_, name)| {
626 name.starts_with(&agent.window_name) || agent.window_name.starts_with(name)
627 });
628
629 if let Some((index, _)) = matching_window {
630 let target = format!("{}:{}", session.session_name, index);
631
632 let _ = Command::new("tmux")
634 .args(["send-keys", "-t", &target, "C-c"])
635 .output();
636
637 std::thread::sleep(Duration::from_millis(200));
639
640 let _ = Command::new("tmux")
642 .args([
643 "send-keys",
644 "-t",
645 &target,
646 "echo 'Agent restarted by user'",
647 "Enter",
648 ])
649 .output();
650
651 self.error = None;
652 self.refresh_live_output();
653 }
654
655 Ok(())
656 }
657
658 pub fn toggle_help(&mut self) {
660 self.show_help = !self.show_help;
661 }
662
663 pub fn scroll_up(&mut self, lines: usize) {
665 let max_scroll = self.live_output.len().saturating_sub(1);
666 self.scroll_offset = (self.scroll_offset + lines).min(max_scroll);
667 self.auto_scroll = false;
668 }
669
670 pub fn scroll_down(&mut self, lines: usize) {
672 self.scroll_offset = self.scroll_offset.saturating_sub(lines);
673 if self.scroll_offset == 0 {
674 self.auto_scroll = true;
675 }
676 }
677
678 pub fn scroll_to_bottom(&mut self) {
680 self.scroll_offset = 0;
681 self.auto_scroll = true;
682 }
683
684 fn reset_scroll(&mut self) {
686 self.scroll_offset = 0;
687 self.auto_scroll = true;
688 }
689
690 pub fn status_counts(&self) -> (usize, usize, usize, usize) {
692 let agents = self.agents();
693 let starting = agents
694 .iter()
695 .filter(|a| a.status == AgentStatus::Starting)
696 .count();
697 let running = agents
698 .iter()
699 .filter(|a| a.status == AgentStatus::Running)
700 .count();
701 let completed = agents
702 .iter()
703 .filter(|a| a.status == AgentStatus::Completed)
704 .count();
705 let failed = agents
706 .iter()
707 .filter(|a| a.status == AgentStatus::Failed)
708 .count();
709 (starting, running, completed, failed)
710 }
711
712 pub fn selected_agent(&self) -> Option<&AgentState> {
714 let agents = self.agents();
715 if agents.is_empty() || self.selected >= agents.len() {
716 None
717 } else {
718 Some(&agents[self.selected])
719 }
720 }
721
722 pub fn refresh_waves(&mut self) {
726 let storage = Storage::new(self.project_root.clone());
728 self.phases = storage.load_tasks().unwrap_or_default();
729
730 if self.swarm_mode {
732 self.waves = self.compute_swarm_waves();
733 self.swarm_progress = self.compute_swarm_progress();
735 return;
736 }
737
738 let running_task_ids: HashSet<String> = self
740 .agents()
741 .iter()
742 .filter(|a| a.status == AgentStatus::Running || a.status == AgentStatus::Starting)
743 .map(|a| a.task_id.clone())
744 .collect();
745
746 let tag = self.active_tag.clone().or_else(|| {
748 self.session.as_ref().map(|s| s.tag.clone())
750 });
751
752 let Some(tag) = tag else {
753 self.waves = Vec::new();
754 return;
755 };
756
757 let Some(phase) = self.phases.get(&tag) else {
758 self.waves = Vec::new();
759 return;
760 };
761
762 self.waves = self.compute_waves(phase, &running_task_ids);
764 }
765
766 fn compute_swarm_progress(&self) -> Option<SwarmProgress> {
771 let swarm = self.swarm_session_data.as_ref()?;
772 let phase = self.phases.get(&swarm.tag);
773
774 let total_waves = self.waves.len();
775
776 let current_wave = swarm
778 .waves
779 .iter()
780 .find(|w| w.completed_at.is_none())
781 .map(|w| w.wave_number)
782 .unwrap_or_else(|| swarm.waves.last().map(|w| w.wave_number).unwrap_or(0));
783
784 let (tasks_completed, tasks_in_progress, tasks_failed, tasks_total) =
786 if let Some(phase) = phase {
787 let swarm_task_ids: HashSet<String> = swarm
789 .waves
790 .iter()
791 .flat_map(|w| w.all_task_ids())
792 .collect();
793
794 let mut completed = 0;
795 let mut in_progress = 0;
796 let mut failed = 0;
797 let total = swarm_task_ids.len();
798
799 for task_id in &swarm_task_ids {
800 if let Some(task) = phase.get_task(task_id) {
801 match task.status {
802 TaskStatus::Done => completed += 1,
803 TaskStatus::InProgress => in_progress += 1,
804 TaskStatus::Blocked => failed += 1,
805 _ => {}
806 }
807 }
808 }
809
810 (completed, in_progress, failed, total)
811 } else {
812 let total = swarm.total_tasks();
814 let failed = swarm.total_failures();
815 (0, 0, failed, total)
816 };
817
818 let (waves_validated, waves_failed_validation) = swarm.waves.iter().fold(
820 (0, 0),
821 |(validated, failed), wave| match &wave.validation {
822 Some(v) if v.all_passed => (validated + 1, failed),
823 Some(_) => (validated, failed + 1),
824 None => (validated, failed),
825 },
826 );
827
828 let total_repairs: usize = swarm.waves.iter().map(|w| w.repairs.len()).sum();
830
831 Some(SwarmProgress {
832 current_wave,
833 total_waves,
834 tasks_completed,
835 tasks_total,
836 tasks_in_progress,
837 tasks_failed,
838 waves_validated,
839 waves_failed_validation,
840 total_repairs,
841 })
842 }
843
844 fn compute_swarm_waves(&self) -> Vec<Wave> {
846 let Some(ref swarm) = self.swarm_session_data else {
847 return Vec::new();
848 };
849
850 let tag = &swarm.tag;
851 let phase = self.phases.get(tag);
852
853 swarm
854 .waves
855 .iter()
856 .map(|wave_state| {
857 let task_ids: Vec<String> = wave_state
859 .rounds
860 .iter()
861 .flat_map(|round| round.task_ids.iter().cloned())
862 .collect();
863
864 let tasks: Vec<WaveTask> = task_ids
865 .iter()
866 .map(|task_id| {
867 let (title, complexity, dependencies, task_status) =
869 if let Some(phase) = phase {
870 if let Some(task) = phase.get_task(task_id) {
871 (
872 task.title.clone(),
873 task.complexity,
874 task.dependencies.clone(),
875 Some(task.status.clone()),
876 )
877 } else {
878 (task_id.clone(), 1, vec![], None)
879 }
880 } else {
881 (task_id.clone(), 1, vec![], None)
882 };
883
884 let state = match task_status {
886 Some(TaskStatus::Done) => WaveTaskState::Done,
887 Some(TaskStatus::InProgress) => WaveTaskState::Running,
888 Some(TaskStatus::Blocked) => WaveTaskState::Blocked,
889 Some(TaskStatus::Pending) => {
890 if wave_state.completed_at.is_some() {
891 WaveTaskState::Blocked
893 } else {
894 WaveTaskState::Ready
895 }
896 }
897 _ => WaveTaskState::Ready,
898 };
899
900 WaveTask {
901 id: task_id.clone(),
902 title,
903 tag: tag.clone(),
904 state,
905 complexity,
906 dependencies,
907 }
908 })
909 .collect();
910
911 Wave {
912 number: wave_state.wave_number,
913 tasks,
914 }
915 })
916 .collect()
917 }
918
919 fn compute_waves(&self, phase: &Phase, running_task_ids: &HashSet<String>) -> Vec<Wave> {
921 let mut actionable: Vec<&Task> = Vec::new();
923 for task in &phase.tasks {
924 if task.status == TaskStatus::Done
925 || task.status == TaskStatus::Expanded
926 || task.status == TaskStatus::Cancelled
927 {
928 continue;
929 }
930
931 if !task.subtasks.is_empty() {
933 continue;
934 }
935
936 if let Some(ref parent_id) = task.parent_id {
938 let parent_expanded = phase
939 .get_task(parent_id)
940 .map(|p| p.is_expanded())
941 .unwrap_or(false);
942 if !parent_expanded {
943 continue;
944 }
945 }
946
947 actionable.push(task);
948 }
949
950 if actionable.is_empty() {
951 return Vec::new();
952 }
953
954 let task_ids: HashSet<String> = actionable.iter().map(|t| t.id.clone()).collect();
956 let mut in_degree: HashMap<String, usize> = HashMap::new();
957 let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
958
959 for task in &actionable {
960 in_degree.entry(task.id.clone()).or_insert(0);
961
962 for dep in &task.dependencies {
963 if task_ids.contains(dep) {
964 *in_degree.entry(task.id.clone()).or_insert(0) += 1;
966 dependents
967 .entry(dep.clone())
968 .or_default()
969 .push(task.id.clone());
970 } else {
971 if !self.is_dependency_satisfied(dep, phase) {
974 *in_degree.entry(task.id.clone()).or_insert(0) += 1000;
976 }
977 }
978 }
979 }
980
981 let mut waves: Vec<Wave> = Vec::new();
983 let mut remaining = in_degree.clone();
984 let mut wave_number = 1;
985
986 while !remaining.is_empty() {
987 let mut ready: Vec<String> = remaining
988 .iter()
989 .filter(|(_, °)| deg == 0)
990 .map(|(id, _)| id.clone())
991 .collect();
992
993 if ready.is_empty() {
994 break; }
996
997 ready.sort();
999
1000 let mut wave_tasks: Vec<WaveTask> = ready
1002 .iter()
1003 .filter_map(|task_id| {
1004 actionable.iter().find(|t| &t.id == task_id).map(|task| {
1005 let state = if task.status == TaskStatus::Done {
1006 WaveTaskState::Done
1007 } else if running_task_ids.contains(&task.id) {
1008 WaveTaskState::Running
1009 } else if task.status == TaskStatus::InProgress {
1010 WaveTaskState::InProgress
1011 } else if task.status == TaskStatus::Blocked {
1012 WaveTaskState::Blocked
1013 } else if self.is_task_ready(task, phase) {
1014 WaveTaskState::Ready
1015 } else {
1016 WaveTaskState::Blocked
1017 };
1018
1019 WaveTask {
1020 id: task.id.clone(),
1021 title: task.title.clone(),
1022 tag: self.active_tag.clone().unwrap_or_default(),
1023 state,
1024 complexity: task.complexity,
1025 dependencies: task.dependencies.clone(),
1026 }
1027 })
1028 })
1029 .collect();
1030
1031 for task_id in &ready {
1033 remaining.remove(task_id);
1034 if let Some(deps) = dependents.get(task_id) {
1035 for dep_id in deps {
1036 if let Some(deg) = remaining.get_mut(dep_id) {
1037 *deg = deg.saturating_sub(1);
1038 }
1039 }
1040 }
1041 }
1042
1043 if !wave_tasks.is_empty() {
1044 wave_tasks.sort_by(|a, b| a.id.cmp(&b.id));
1046 waves.push(Wave {
1047 number: wave_number,
1048 tasks: wave_tasks,
1049 });
1050 }
1051 wave_number += 1;
1052 }
1053
1054 waves
1055 }
1056
1057 fn is_task_ready(&self, task: &Task, phase: &Phase) -> bool {
1059 if task.status != TaskStatus::Pending {
1060 return false;
1061 }
1062
1063 for dep_id in &task.dependencies {
1065 if !self.is_dependency_satisfied(dep_id, phase) {
1066 return false;
1067 }
1068 }
1069
1070 true
1071 }
1072
1073 fn is_dependency_satisfied(&self, dep_id: &str, phase: &Phase) -> bool {
1075 let Some(dep) = phase.get_task(dep_id) else {
1076 return true; };
1078
1079 match dep.status {
1080 TaskStatus::Done => true,
1081 TaskStatus::Expanded => {
1082 if dep.subtasks.is_empty() {
1084 false } else {
1086 dep.subtasks.iter().all(|subtask_id| {
1087 phase
1088 .get_task(subtask_id)
1089 .map(|st| st.status == TaskStatus::Done)
1090 .unwrap_or(false)
1091 })
1092 }
1093 }
1094 _ => false, }
1096 }
1097
1098 pub fn all_wave_tasks(&self) -> Vec<&WaveTask> {
1100 self.waves.iter().flat_map(|w| w.tasks.iter()).collect()
1101 }
1102
1103 pub fn selected_wave_task(&self) -> Option<&WaveTask> {
1105 let all_tasks = self.all_wave_tasks();
1106 all_tasks.get(self.wave_task_index).copied()
1107 }
1108
1109 pub fn next_panel(&mut self) {
1113 self.focused_panel = match self.focused_panel {
1114 FocusedPanel::Waves => FocusedPanel::Agents,
1115 FocusedPanel::Agents => FocusedPanel::Output,
1116 FocusedPanel::Output => FocusedPanel::Waves,
1117 };
1118 }
1119
1120 pub fn previous_panel(&mut self) {
1122 self.focused_panel = match self.focused_panel {
1123 FocusedPanel::Waves => FocusedPanel::Output,
1124 FocusedPanel::Agents => FocusedPanel::Waves,
1125 FocusedPanel::Output => FocusedPanel::Agents,
1126 };
1127 }
1128
1129 pub fn move_up(&mut self) {
1131 match self.focused_panel {
1132 FocusedPanel::Waves => {
1133 if self.wave_task_index > 0 {
1134 self.wave_task_index -= 1;
1135 self.adjust_wave_scroll();
1136 }
1137 }
1138 FocusedPanel::Agents => self.previous_agent(),
1139 FocusedPanel::Output => self.scroll_up(1),
1140 }
1141 }
1142
1143 pub fn move_down(&mut self) {
1145 match self.focused_panel {
1146 FocusedPanel::Waves => {
1147 let max = self.all_wave_tasks().len().saturating_sub(1);
1148 if self.wave_task_index < max {
1149 self.wave_task_index += 1;
1150 self.adjust_wave_scroll();
1151 }
1152 }
1153 FocusedPanel::Agents => self.next_agent(),
1154 FocusedPanel::Output => self.scroll_down(1),
1155 }
1156 }
1157
1158 fn adjust_wave_scroll(&mut self) {
1161 let mut line_idx = 0;
1164 let mut found = false;
1165 let mut task_counter = 0;
1166
1167 for wave in &self.waves {
1168 line_idx += 1; for _ in &wave.tasks {
1170 if task_counter == self.wave_task_index {
1171 found = true;
1172 break;
1173 }
1174 line_idx += 1;
1175 task_counter += 1;
1176 }
1177 if found {
1178 break;
1179 }
1180 }
1181
1182 let visible_height = 4;
1184
1185 if line_idx < self.wave_scroll_offset {
1187 self.wave_scroll_offset = line_idx;
1188 } else if line_idx >= self.wave_scroll_offset + visible_height {
1189 self.wave_scroll_offset = line_idx.saturating_sub(visible_height - 1);
1190 }
1191 }
1192
1193 pub fn toggle_task_selection(&mut self) {
1197 if let Some(task) = self.selected_wave_task() {
1198 let task_id = task.id.clone();
1199 if self.selected_tasks.contains(&task_id) {
1200 self.selected_tasks.remove(&task_id);
1201 } else {
1202 if task.state == WaveTaskState::Ready {
1204 self.selected_tasks.insert(task_id);
1205 }
1206 }
1207 }
1208 }
1209
1210 pub fn select_all_ready(&mut self) {
1212 for wave in &self.waves {
1213 for task in &wave.tasks {
1214 if task.state == WaveTaskState::Ready {
1215 self.selected_tasks.insert(task.id.clone());
1216 }
1217 }
1218 }
1219 }
1220
1221 pub fn clear_selection(&mut self) {
1223 self.selected_tasks.clear();
1224 }
1225
1226 pub fn ready_task_count(&self) -> usize {
1228 self.waves
1229 .iter()
1230 .flat_map(|w| &w.tasks)
1231 .filter(|t| t.state == WaveTaskState::Ready)
1232 .count()
1233 }
1234
1235 pub fn selected_task_count(&self) -> usize {
1237 self.selected_tasks.len()
1238 }
1239
1240 pub fn get_selected_tasks(&self) -> Vec<&WaveTask> {
1242 self.all_wave_tasks()
1243 .into_iter()
1244 .filter(|t| self.selected_tasks.contains(&t.id))
1245 .collect()
1246 }
1247
1248 pub fn spawn_selected_tasks(&mut self) -> Result<usize> {
1251 use crate::commands::spawn::{agent, terminal};
1252
1253 let tasks_to_spawn: Vec<(String, String, String)> = self
1254 .get_selected_tasks()
1255 .iter()
1256 .map(|t| (t.id.clone(), t.title.clone(), t.tag.clone()))
1257 .collect();
1258
1259 if tasks_to_spawn.is_empty() {
1260 return Ok(0);
1261 }
1262
1263 let working_dir = self
1265 .project_root
1266 .clone()
1267 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1268
1269 let session = match &self.session {
1271 Some(s) => s,
1272 None => {
1273 self.error = Some("No session loaded".to_string());
1274 return Ok(0);
1275 }
1276 };
1277
1278 let session_name = session.session_name.clone();
1279 let mut spawned_count = 0;
1280
1281 let storage = Storage::new(self.project_root.clone());
1283
1284 for (task_id, task_title, tag) in &tasks_to_spawn {
1285 let phase = match self.phases.get(tag) {
1287 Some(p) => p,
1288 None => continue,
1289 };
1290
1291 let task = match phase.get_task(task_id) {
1292 Some(t) => t,
1293 None => continue,
1294 };
1295
1296 let prompt = agent::generate_prompt(task, tag);
1298
1299 match terminal::spawn_terminal(task_id, &prompt, &working_dir, &session_name) {
1301 Ok(_window_index) => {
1302 spawned_count += 1;
1303
1304 if let Some(ref mut session) = self.session {
1306 session.add_agent(task_id, task_title, tag);
1307 }
1308
1309 if let Ok(mut phase) = storage.load_group(tag) {
1311 if let Some(task) = phase.get_task_mut(task_id) {
1312 task.set_status(TaskStatus::InProgress);
1313 let _ = storage.update_group(tag, &phase);
1314 }
1315 }
1316 }
1317 Err(e) => {
1318 self.error = Some(format!("Failed to spawn {}: {}", task_id, e));
1319 }
1320 }
1321
1322 if spawned_count < tasks_to_spawn.len() {
1324 std::thread::sleep(Duration::from_millis(300));
1325 }
1326 }
1327
1328 if spawned_count > 0 {
1330 if let Some(ref session) = self.session {
1331 let _ = crate::commands::spawn::monitor::save_session(
1332 self.project_root.as_ref(),
1333 session,
1334 );
1335 }
1336
1337 self.selected_tasks.clear();
1339 self.refresh()?;
1340 self.refresh_waves();
1341 }
1342
1343 Ok(spawned_count)
1344 }
1345
1346 pub fn prepare_swarm_start(&self) -> Option<(String, String)> {
1348 let tag = self
1350 .session
1351 .as_ref()
1352 .map(|s| s.tag.clone())
1353 .or_else(|| self.active_tag.clone())?;
1354
1355 let session_base = self.session_name.replace("swarm-", "").replace("scud-", "");
1357 let cmd = format!("scud swarm --tag {} --session {}", tag, session_base);
1358
1359 Some((cmd, tag))
1360 }
1361
1362 pub fn set_selected_task_status(&mut self, new_status: TaskStatus) -> Result<()> {
1364 let Some(ref session) = self.session else {
1365 self.error = Some("No session loaded".to_string());
1366 return Ok(());
1367 };
1368
1369 let agents = session.agents.clone();
1370 if agents.is_empty() || self.selected >= agents.len() {
1371 self.error = Some("No agent selected".to_string());
1372 return Ok(());
1373 }
1374
1375 let agent = &agents[self.selected];
1376 let task_id = &agent.task_id;
1377 let tag = &agent.tag;
1378
1379 let storage = Storage::new(self.project_root.clone());
1381 if let Ok(mut phase) = storage.load_group(tag) {
1382 if let Some(task) = phase.get_task_mut(task_id) {
1383 task.set_status(new_status.clone());
1384 if let Err(e) = storage.update_group(tag, &phase) {
1385 self.error = Some(format!("Failed to save: {}", e));
1386 return Ok(());
1387 }
1388 self.error = Some(format!("✓ {} → {}", task_id, new_status.as_str()));
1390 } else {
1391 self.error = Some(format!("Task {} not found", task_id));
1392 }
1393 } else {
1394 self.error = Some(format!("Failed to load phase {}", tag));
1395 }
1396
1397 self.refresh()?;
1399 self.refresh_waves();
1400
1401 Ok(())
1402 }
1403
1404 fn spawn_task_with_ralph(&mut self, task_id: &str) -> Result<()> {
1407 use crate::commands::spawn::{agent, terminal};
1408
1409 let task_info = self
1411 .waves
1412 .iter()
1413 .flat_map(|w| w.tasks.iter())
1414 .find(|t| t.id == task_id)
1415 .map(|t| (t.id.clone(), t.title.clone(), t.tag.clone()));
1416
1417 let (task_id, task_title, tag) = match task_info {
1418 Some(info) => info,
1419 None => return Ok(()),
1420 };
1421
1422 let working_dir = self
1424 .project_root
1425 .clone()
1426 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1427
1428 let session = match &self.session {
1430 Some(s) => s,
1431 None => {
1432 self.error = Some("No session loaded".to_string());
1433 return Ok(());
1434 }
1435 };
1436
1437 let session_name = session.session_name.clone();
1438
1439 let storage = Storage::new(self.project_root.clone());
1441
1442 let phase = match self.phases.get(&tag) {
1443 Some(p) => p,
1444 None => return Ok(()),
1445 };
1446
1447 let task = match phase.get_task(&task_id) {
1448 Some(t) => t,
1449 None => return Ok(()),
1450 };
1451
1452 let base_prompt = agent::generate_prompt(task, &tag);
1454 let ralph_prompt = format!(
1455 r#"{}
1456
1457═══════════════════════════════════════════════════════════
1458RALPH LOOP MODE - Autonomous Task Completion
1459═══════════════════════════════════════════════════════════
1460
1461CRITICAL: Your task ID is **{task_id}** (NOT any parent task!)
1462
1463You are in a Ralph loop. Keep working until the task is COMPLETE.
1464
1465After EACH attempt:
14661. Run EXACTLY: scud set-status {task_id} done
1467 ⚠️ Use task ID "{task_id}" - do NOT use any other task ID!
14682. Verify the task is truly done (tests pass, code works)
14693. If something failed, fix it and try again
1470
1471The loop will continue until task {task_id} is marked done.
1472Do NOT give up. Keep iterating until success.
1473
1474When you have genuinely completed task {task_id}, output:
1475<promise>TASK {task_id} COMPLETE</promise>
1476
1477DO NOT output this promise unless task {task_id} is TRULY complete!
1478═══════════════════════════════════════════════════════════
1479"#,
1480 base_prompt,
1481 task_id = task_id
1482 );
1483
1484 match terminal::spawn_terminal_ralph(
1486 &task_id,
1487 &ralph_prompt,
1488 &working_dir,
1489 &session_name,
1490 &format!("TASK {} COMPLETE", task_id),
1491 ) {
1492 Ok(()) => {
1493 if let Some(ref mut session) = self.session {
1495 session.add_agent(&task_id, &task_title, &tag);
1496 }
1497
1498 if let Ok(mut phase) = storage.load_group(&tag) {
1500 if let Some(task) = phase.get_task_mut(&task_id) {
1501 task.set_status(TaskStatus::InProgress);
1502 let _ = storage.update_group(&tag, &phase);
1503 }
1504 }
1505
1506 if let Some(ref session) = self.session {
1508 let _ = crate::commands::spawn::monitor::save_session(
1509 self.project_root.as_ref(),
1510 session,
1511 );
1512 }
1513
1514 let _ = self.refresh();
1516 self.refresh_waves();
1517 }
1518 Err(e) => {
1519 self.error = Some(format!(
1520 "Failed to spawn Ralph agent for {}: {}",
1521 task_id, e
1522 ));
1523 }
1524 }
1525
1526 Ok(())
1527 }
1528}