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::headless::StreamStore;
17use crate::commands::spawn::monitor::{
18 load_session, save_session, AgentState, AgentStatus, SpawnSession,
19};
20use crate::commands::swarm::session as swarm_session;
21use crate::models::phase::Phase;
22use crate::models::task::{Task, TaskStatus};
23use crate::storage::Storage;
24
25#[derive(Debug, Clone, PartialEq)]
27pub enum ViewMode {
28 Split,
30 Fullscreen,
32 Input,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq)]
38pub enum FocusedPanel {
39 Waves,
40 Agents,
41 Output,
42}
43
44#[derive(Debug, Clone, PartialEq)]
46pub enum WaveTaskState {
47 Ready,
49 Running,
51 Done,
53 Blocked,
55 InProgress,
57}
58
59#[derive(Debug, Clone)]
61pub struct WaveTask {
62 pub id: String,
63 pub title: String,
64 pub tag: String,
65 pub state: WaveTaskState,
66 pub complexity: u32,
67 pub dependencies: Vec<String>,
68}
69
70#[derive(Debug, Clone)]
72pub struct Wave {
73 pub number: usize,
74 pub tasks: Vec<WaveTask>,
75}
76
77#[derive(Debug, Clone, Default)]
84pub struct SwarmProgress {
85 pub current_wave: usize,
87 pub total_waves: usize,
89 pub tasks_completed: usize,
91 pub tasks_total: usize,
93 pub tasks_in_progress: usize,
95 pub tasks_failed: usize,
97 pub waves_validated: usize,
99 pub waves_failed_validation: usize,
101 pub total_repairs: usize,
103}
104
105pub struct App {
107 pub project_root: Option<PathBuf>,
109 pub session_name: String,
111 pub session: Option<SpawnSession>,
113 pub selected: usize,
115 pub view_mode: ViewMode,
117 pub show_help: bool,
119 last_refresh: Instant,
121 refresh_interval: Duration,
123 pub error: Option<String>,
125 pub live_output: Vec<String>,
127 last_output_refresh: Instant,
129 output_refresh_interval: Duration,
131 pub input_buffer: String,
133 pub scroll_offset: usize,
135 pub auto_scroll: bool,
137
138 pub focused_panel: FocusedPanel,
141 pub waves: Vec<Wave>,
143 pub selected_tasks: HashSet<String>,
145 pub wave_task_index: usize,
147 pub wave_scroll_offset: usize,
149 pub agents_scroll_offset: usize,
151 pub active_tag: Option<String>,
153 phases: HashMap<String, Phase>,
155
156 pub ralph_mode: bool,
159 pub ralph_max_parallel: usize,
161 last_ralph_check: Instant,
163
164 pub swarm_mode: bool,
167 pub swarm_session_data: Option<swarm_session::SwarmSession>,
169 pub swarm_progress: Option<SwarmProgress>,
171
172 pub stream_store: Option<StreamStore>,
175}
176
177impl App {
178 pub fn new(
186 project_root: Option<PathBuf>,
187 session_name: &str,
188 swarm_mode: bool,
189 stream_store: Option<StreamStore>,
190 ) -> Result<Self> {
191 let storage = Storage::new(project_root.clone());
193 let active_tag = storage.get_active_group().ok().flatten();
194 let phases = storage.load_tasks().unwrap_or_default();
195
196 let mut app = Self {
197 project_root,
198 session_name: session_name.to_string(),
199 session: None,
200 selected: 0,
201 view_mode: ViewMode::Split,
202 show_help: false,
203 last_refresh: Instant::now(),
204 refresh_interval: Duration::from_secs(2),
205 error: None,
206 live_output: Vec::new(),
207 last_output_refresh: Instant::now(),
208 output_refresh_interval: Duration::from_millis(500),
209 input_buffer: String::new(),
210 scroll_offset: 0,
211 auto_scroll: true,
212 focused_panel: FocusedPanel::Waves,
214 waves: Vec::new(),
215 selected_tasks: HashSet::new(),
216 wave_task_index: 0,
217 wave_scroll_offset: 0,
218 agents_scroll_offset: 0,
219 active_tag,
220 phases,
221 ralph_mode: false,
223 ralph_max_parallel: 5,
224 last_ralph_check: Instant::now(),
225 swarm_mode,
227 swarm_session_data: None,
228 swarm_progress: None,
229 stream_store,
231 };
232 app.refresh()?;
233 app.refresh_waves();
234 app.refresh_live_output();
235 Ok(app)
236 }
237
238 pub fn refresh(&mut self) -> Result<()> {
240 if self.swarm_mode {
241 match swarm_session::load_session(self.project_root.as_ref(), &self.session_name) {
243 Ok(session) => {
244 self.swarm_session_data = Some(session);
245 self.error = None;
246 self.swarm_progress = self.compute_swarm_progress();
248 }
249 Err(e) => {
250 self.error = Some(format!("Failed to load swarm session: {}", e));
251 self.swarm_progress = None;
252 }
253 }
254 } else {
255 match load_session(self.project_root.as_ref(), &self.session_name) {
257 Ok(mut session) => {
258 self.refresh_agent_statuses(&mut session);
260
261 let _ = save_session(self.project_root.as_ref(), &session);
263
264 self.session = Some(session);
265 self.error = None;
266 }
267 Err(e) => {
268 self.error = Some(format!("Failed to load session: {}", e));
269 }
270 }
271 }
272 self.last_refresh = Instant::now();
273 Ok(())
274 }
275
276 pub fn refresh_live_output(&mut self) {
280 if let Some(ref store) = self.stream_store {
282 let agents = self.agents();
283 if agents.is_empty() || self.selected >= agents.len() {
284 self.live_output = vec!["No agent selected".to_string()];
285 return;
286 }
287
288 let agent = &agents[self.selected];
289 self.live_output = store.get_output(&agent.task_id, 100);
290
291 if self.live_output.is_empty() {
292 self.live_output = vec!["Waiting for output...".to_string()];
293 }
294
295 self.last_output_refresh = Instant::now();
296 return;
297 }
298
299 let agents = self.agents();
301 if agents.is_empty() || self.selected >= agents.len() {
302 self.live_output = vec!["No agent selected".to_string()];
303 return;
304 }
305
306 let agent = &agents[self.selected];
307 let session = match &self.session {
308 Some(s) => s,
309 None => {
310 self.live_output = vec!["No session loaded".to_string()];
311 return;
312 }
313 };
314
315 let tmux_windows = self.get_tmux_windows(&session.session_name);
317 let window_target = match self.window_target_for(
318 &session.session_name,
319 &agent.window_name,
320 &tmux_windows,
321 ) {
322 Some(target) => target,
323 None => {
324 self.live_output = vec![format!("Window '{}' not found", agent.window_name)];
325 return;
326 }
327 };
328
329 let output = Command::new("tmux")
331 .args([
332 "capture-pane",
333 "-t",
334 &window_target,
335 "-p", "-S",
337 "-100", ])
339 .output();
340
341 match output {
342 Ok(out) if out.status.success() => {
343 let content = String::from_utf8_lossy(&out.stdout);
344 self.live_output = content.lines().map(|s| s.to_string()).collect();
345
346 while self
348 .live_output
349 .last()
350 .map(|s| s.trim().is_empty())
351 .unwrap_or(false)
352 {
353 self.live_output.pop();
354 }
355 }
356 Ok(out) => {
357 self.live_output = vec![format!("Error: {}", String::from_utf8_lossy(&out.stderr))];
358 }
359 Err(e) => {
360 self.live_output = vec![format!("tmux error: {}", e)];
361 }
362 }
363
364 self.last_output_refresh = Instant::now();
365 }
366
367 fn refresh_agent_statuses(&self, session: &mut SpawnSession) {
369 let tmux_windows = self.get_tmux_windows(&session.session_name);
370 let storage = Storage::new(self.project_root.clone());
371 let all_phases = storage.load_tasks().ok();
372
373 for agent in &mut session.agents {
374 let window_exists = self
375 .find_window_index(&agent.window_name, &tmux_windows)
376 .is_some();
377
378 let task_status = all_phases.as_ref().and_then(|phases| {
379 phases.get(&agent.tag).and_then(|phase| {
380 phase
381 .get_task(&agent.task_id)
382 .map(|task| task.status.clone())
383 })
384 });
385
386 agent.status = match (&task_status, window_exists) {
387 (Some(TaskStatus::Done), _) => AgentStatus::Completed,
388 (Some(TaskStatus::Blocked), _) => AgentStatus::Failed,
389 (Some(TaskStatus::InProgress), true) => AgentStatus::Running,
390 (Some(TaskStatus::InProgress), false) => AgentStatus::Failed,
392 (_, false) => AgentStatus::Completed,
393 (_, true) => AgentStatus::Running,
394 };
395 }
396 }
397
398 fn get_tmux_windows(&self, session_name: &str) -> Vec<(usize, String)> {
400 let output = Command::new("tmux")
401 .args([
402 "list-windows",
403 "-t",
404 session_name,
405 "-F",
406 "#{window_index}:#{window_name}",
407 ])
408 .output();
409
410 match output {
411 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
412 .lines()
413 .filter_map(|line| {
414 let parts: Vec<&str> = line.splitn(2, ':').collect();
415 if parts.len() == 2 {
416 parts[0].parse().ok().map(|idx| (idx, parts[1].to_string()))
417 } else {
418 None
419 }
420 })
421 .collect(),
422 _ => Vec::new(),
423 }
424 }
425
426 fn window_name_matches(expected: &str, observed: &str) -> bool {
427 observed.starts_with(expected) || expected.starts_with(observed)
428 }
429
430 fn find_window_index(
431 &self,
432 window_name: &str,
433 tmux_windows: &[(usize, String)],
434 ) -> Option<usize> {
435 tmux_windows
436 .iter()
437 .find(|(_, observed_name)| Self::window_name_matches(window_name, observed_name))
438 .map(|(index, _)| *index)
439 }
440
441 fn window_target_for(
442 &self,
443 session_name: &str,
444 window_name: &str,
445 tmux_windows: &[(usize, String)],
446 ) -> Option<String> {
447 self.find_window_index(window_name, tmux_windows)
448 .map(|index| format!("{}:{}", session_name, index))
449 }
450
451 pub fn tick(&mut self) -> Result<()> {
453 if self.last_refresh.elapsed() >= self.refresh_interval {
455 self.refresh()?;
456 self.refresh_waves();
457 }
458
459 if self.last_output_refresh.elapsed() >= self.output_refresh_interval {
461 self.refresh_live_output();
462 }
463
464 if self.ralph_mode && self.last_ralph_check.elapsed() >= Duration::from_secs(5) {
466 self.ralph_auto_spawn();
467 self.last_ralph_check = Instant::now();
468 }
469
470 Ok(())
471 }
472
473 pub fn toggle_ralph_mode(&mut self) {
475 self.ralph_mode = !self.ralph_mode;
476 if self.ralph_mode {
477 self.ralph_auto_spawn();
479 }
480 }
481
482 fn ralph_auto_spawn(&mut self) {
484 let running_count = self
486 .agents()
487 .iter()
488 .filter(|a| a.status == AgentStatus::Running || a.status == AgentStatus::Starting)
489 .count();
490
491 if running_count >= self.ralph_max_parallel {
492 return; }
494
495 let slots_available = self.ralph_max_parallel - running_count;
497 let mut tasks_to_spawn: Vec<String> = Vec::new();
498
499 for wave in &self.waves {
500 for task in &wave.tasks {
501 if task.state == WaveTaskState::Ready && !self.selected_tasks.contains(&task.id) {
502 let already_spawned = self.agents().iter().any(|a| a.task_id == task.id);
504 if !already_spawned {
505 tasks_to_spawn.push(task.id.clone());
506 if tasks_to_spawn.len() >= slots_available {
507 break;
508 }
509 }
510 }
511 }
512 if tasks_to_spawn.len() >= slots_available {
513 break;
514 }
515 }
516
517 for task_id in tasks_to_spawn {
519 let _ = self.spawn_task_with_ralph(&task_id);
520 }
521 }
522
523 pub fn agents(&self) -> &[AgentState] {
525 self.session
526 .as_ref()
527 .map(|s| s.agents.as_slice())
528 .unwrap_or(&[])
529 }
530
531 pub fn next_agent(&mut self) {
533 let len = self.agents().len();
534 if len > 0 {
535 self.selected = (self.selected + 1) % len;
536 self.adjust_agents_scroll();
537 self.reset_scroll();
538 self.refresh_live_output();
539 }
540 }
541
542 pub fn previous_agent(&mut self) {
544 let len = self.agents().len();
545 if len > 0 {
546 self.selected = if self.selected > 0 {
547 self.selected - 1
548 } else {
549 len - 1
550 };
551 self.adjust_agents_scroll();
552 self.reset_scroll();
553 self.refresh_live_output();
554 }
555 }
556
557 pub fn adjust_agents_scroll(&mut self) {
560 const VISIBLE_LINES: usize = 8;
561
562 if self.selected < self.agents_scroll_offset {
564 self.agents_scroll_offset = self.selected;
565 }
566 else if self.selected >= self.agents_scroll_offset + VISIBLE_LINES {
568 self.agents_scroll_offset = self.selected.saturating_sub(VISIBLE_LINES - 1);
569 }
570 }
571
572 pub fn toggle_fullscreen(&mut self) {
574 self.view_mode = match self.view_mode {
575 ViewMode::Split => ViewMode::Fullscreen,
576 ViewMode::Fullscreen => ViewMode::Split,
577 ViewMode::Input => ViewMode::Fullscreen,
578 };
579 }
580
581 pub fn exit_fullscreen(&mut self) {
583 self.view_mode = ViewMode::Split;
584 self.input_buffer.clear();
585 }
586
587 pub fn enter_input_mode(&mut self) {
589 self.view_mode = ViewMode::Input;
590 self.input_buffer.clear();
591 }
592
593 pub fn input_char(&mut self, c: char) {
595 self.input_buffer.push(c);
596 }
597
598 pub fn input_backspace(&mut self) {
600 self.input_buffer.pop();
601 }
602
603 pub fn send_input(&mut self) -> Result<()> {
605 if self.input_buffer.is_empty() {
606 return Ok(());
607 }
608
609 let session = match &self.session {
610 Some(s) => s,
611 None => {
612 self.error = Some("No session loaded".to_string());
613 return Ok(());
614 }
615 };
616
617 let agents = self.agents();
618 if agents.is_empty() || self.selected >= agents.len() {
619 self.error = Some("No agent selected".to_string());
620 return Ok(());
621 }
622
623 let agent = &agents[self.selected];
624
625 let tmux_windows = self.get_tmux_windows(&session.session_name);
627 let window_target = match self.window_target_for(
628 &session.session_name,
629 &agent.window_name,
630 &tmux_windows,
631 ) {
632 Some(target) => target,
633 None => {
634 self.error = Some(format!("Window not found for {}", agent.task_id));
635 return Ok(());
636 }
637 };
638
639 let result = Command::new("tmux")
641 .args([
642 "send-keys",
643 "-t",
644 &window_target,
645 &self.input_buffer,
646 "Enter",
647 ])
648 .output();
649
650 match result {
651 Ok(out) if out.status.success() => {
652 self.error = None;
653 self.input_buffer.clear();
654 self.view_mode = ViewMode::Fullscreen; self.refresh_live_output();
656 }
657 Ok(out) => {
658 self.error = Some(format!(
659 "Send failed: {}",
660 String::from_utf8_lossy(&out.stderr)
661 ));
662 }
663 Err(e) => {
664 self.error = Some(format!("tmux error: {}", e));
665 }
666 }
667
668 Ok(())
669 }
670
671 pub fn restart_agent(&mut self) -> Result<()> {
673 let session = match &self.session {
674 Some(s) => s,
675 None => return Ok(()),
676 };
677
678 let agents = self.agents();
679 if agents.is_empty() || self.selected >= agents.len() {
680 return Ok(());
681 }
682
683 let agent = &agents[self.selected];
684
685 let tmux_windows = self.get_tmux_windows(&session.session_name);
687 if let Some(target) =
688 self.window_target_for(&session.session_name, &agent.window_name, &tmux_windows)
689 {
690 let _ = Command::new("tmux")
692 .args(["send-keys", "-t", &target, "C-c"])
693 .output();
694
695 std::thread::sleep(Duration::from_millis(200));
697
698 let _ = Command::new("tmux")
700 .args([
701 "send-keys",
702 "-t",
703 &target,
704 "echo 'Agent restarted by user'",
705 "Enter",
706 ])
707 .output();
708
709 self.error = None;
710 self.refresh_live_output();
711 }
712
713 Ok(())
714 }
715
716 pub fn toggle_help(&mut self) {
718 self.show_help = !self.show_help;
719 }
720
721 pub fn scroll_up(&mut self, lines: usize) {
723 let max_scroll = self.live_output.len().saturating_sub(1);
724 self.scroll_offset = (self.scroll_offset + lines).min(max_scroll);
725 self.auto_scroll = false;
726 }
727
728 pub fn scroll_down(&mut self, lines: usize) {
730 self.scroll_offset = self.scroll_offset.saturating_sub(lines);
731 if self.scroll_offset == 0 {
732 self.auto_scroll = true;
733 }
734 }
735
736 pub fn scroll_to_bottom(&mut self) {
738 self.scroll_offset = 0;
739 self.auto_scroll = true;
740 }
741
742 fn reset_scroll(&mut self) {
744 self.scroll_offset = 0;
745 self.auto_scroll = true;
746 }
747
748 pub fn status_counts(&self) -> (usize, usize, usize, usize) {
750 let agents = self.agents();
751 let starting = agents
752 .iter()
753 .filter(|a| a.status == AgentStatus::Starting)
754 .count();
755 let running = agents
756 .iter()
757 .filter(|a| a.status == AgentStatus::Running)
758 .count();
759 let completed = agents
760 .iter()
761 .filter(|a| a.status == AgentStatus::Completed)
762 .count();
763 let failed = agents
764 .iter()
765 .filter(|a| a.status == AgentStatus::Failed)
766 .count();
767 (starting, running, completed, failed)
768 }
769
770 pub fn selected_agent(&self) -> Option<&AgentState> {
772 let agents = self.agents();
773 if agents.is_empty() || self.selected >= agents.len() {
774 None
775 } else {
776 Some(&agents[self.selected])
777 }
778 }
779
780 pub fn refresh_waves(&mut self) {
784 let storage = Storage::new(self.project_root.clone());
786 self.phases = storage.load_tasks().unwrap_or_default();
787
788 if self.swarm_mode {
790 self.waves = self.compute_swarm_waves();
791 self.swarm_progress = self.compute_swarm_progress();
793 return;
794 }
795
796 let running_task_ids: HashSet<String> = self
798 .agents()
799 .iter()
800 .filter(|a| a.status == AgentStatus::Running || a.status == AgentStatus::Starting)
801 .map(|a| a.task_id.clone())
802 .collect();
803
804 let tag = self.active_tag.clone().or_else(|| {
806 self.session.as_ref().map(|s| s.tag.clone())
808 });
809
810 let Some(tag) = tag else {
811 self.waves = Vec::new();
812 return;
813 };
814
815 let Some(phase) = self.phases.get(&tag) else {
816 self.waves = Vec::new();
817 return;
818 };
819
820 self.waves = self.compute_waves(phase, &running_task_ids);
822 }
823
824 fn compute_swarm_progress(&self) -> Option<SwarmProgress> {
829 let swarm = self.swarm_session_data.as_ref()?;
830 let phase = self.phases.get(&swarm.tag);
831
832 let total_waves = self.waves.len();
833
834 let current_wave = swarm
836 .waves
837 .iter()
838 .find(|w| w.completed_at.is_none())
839 .map(|w| w.wave_number)
840 .unwrap_or_else(|| swarm.waves.last().map(|w| w.wave_number).unwrap_or(0));
841
842 let (tasks_completed, tasks_in_progress, tasks_failed, tasks_total) =
844 if let Some(phase) = phase {
845 let swarm_task_ids: HashSet<String> =
847 swarm.waves.iter().flat_map(|w| w.all_task_ids()).collect();
848
849 let mut completed = 0;
850 let mut in_progress = 0;
851 let mut failed = 0;
852 let total = swarm_task_ids.len();
853
854 for task_id in &swarm_task_ids {
855 if let Some(task) = phase.get_task(task_id) {
856 match task.status {
857 TaskStatus::Done => completed += 1,
858 TaskStatus::InProgress => in_progress += 1,
859 TaskStatus::Blocked => failed += 1,
860 _ => {}
861 }
862 }
863 }
864
865 (completed, in_progress, failed, total)
866 } else {
867 let total = swarm.total_tasks();
869 let failed = swarm.total_failures();
870 (0, 0, failed, total)
871 };
872
873 let (waves_validated, waves_failed_validation) =
875 swarm
876 .waves
877 .iter()
878 .fold((0, 0), |(validated, failed), wave| match &wave.validation {
879 Some(v) if v.all_passed => (validated + 1, failed),
880 Some(_) => (validated, failed + 1),
881 None => (validated, failed),
882 });
883
884 let total_repairs: usize = swarm.waves.iter().map(|w| w.repairs.len()).sum();
886
887 Some(SwarmProgress {
888 current_wave,
889 total_waves,
890 tasks_completed,
891 tasks_total,
892 tasks_in_progress,
893 tasks_failed,
894 waves_validated,
895 waves_failed_validation,
896 total_repairs,
897 })
898 }
899
900 fn compute_swarm_waves(&self) -> Vec<Wave> {
902 let Some(ref swarm) = self.swarm_session_data else {
903 return Vec::new();
904 };
905
906 let tag = &swarm.tag;
907 let phase = self.phases.get(tag);
908
909 swarm
910 .waves
911 .iter()
912 .map(|wave_state| {
913 let task_ids: Vec<String> = wave_state
915 .rounds
916 .iter()
917 .flat_map(|round| round.task_ids.iter().cloned())
918 .collect();
919
920 let tasks: Vec<WaveTask> = task_ids
921 .iter()
922 .map(|task_id| {
923 let (title, complexity, dependencies, task_status) =
925 if let Some(phase) = phase {
926 if let Some(task) = phase.get_task(task_id) {
927 (
928 task.title.clone(),
929 task.complexity,
930 task.dependencies.clone(),
931 Some(task.status.clone()),
932 )
933 } else {
934 (task_id.clone(), 1, vec![], None)
935 }
936 } else {
937 (task_id.clone(), 1, vec![], None)
938 };
939
940 let state = match task_status {
942 Some(TaskStatus::Done) => WaveTaskState::Done,
943 Some(TaskStatus::InProgress) => WaveTaskState::Running,
944 Some(TaskStatus::Blocked) => WaveTaskState::Blocked,
945 Some(TaskStatus::Pending) => {
946 if wave_state.completed_at.is_some() {
947 WaveTaskState::Blocked
949 } else {
950 WaveTaskState::Ready
951 }
952 }
953 _ => WaveTaskState::Ready,
954 };
955
956 WaveTask {
957 id: task_id.clone(),
958 title,
959 tag: tag.clone(),
960 state,
961 complexity,
962 dependencies,
963 }
964 })
965 .collect();
966
967 Wave {
968 number: wave_state.wave_number,
969 tasks,
970 }
971 })
972 .collect()
973 }
974
975 fn compute_waves(&self, phase: &Phase, running_task_ids: &HashSet<String>) -> Vec<Wave> {
977 let mut actionable: Vec<&Task> = Vec::new();
979 for task in &phase.tasks {
980 if task.status == TaskStatus::Done
981 || task.status == TaskStatus::Expanded
982 || task.status == TaskStatus::Cancelled
983 {
984 continue;
985 }
986
987 if !task.subtasks.is_empty() {
989 continue;
990 }
991
992 if let Some(ref parent_id) = task.parent_id {
994 let parent_expanded = phase
995 .get_task(parent_id)
996 .map(|p| p.is_expanded())
997 .unwrap_or(false);
998 if !parent_expanded {
999 continue;
1000 }
1001 }
1002
1003 actionable.push(task);
1004 }
1005
1006 if actionable.is_empty() {
1007 return Vec::new();
1008 }
1009
1010 let task_ids: HashSet<String> = actionable.iter().map(|t| t.id.clone()).collect();
1012 let mut in_degree: HashMap<String, usize> = HashMap::new();
1013 let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
1014
1015 for task in &actionable {
1016 in_degree.entry(task.id.clone()).or_insert(0);
1017
1018 for dep in &task.dependencies {
1019 if task_ids.contains(dep) {
1020 *in_degree.entry(task.id.clone()).or_insert(0) += 1;
1022 dependents
1023 .entry(dep.clone())
1024 .or_default()
1025 .push(task.id.clone());
1026 } else {
1027 if !self.is_dependency_satisfied(dep, phase) {
1030 *in_degree.entry(task.id.clone()).or_insert(0) += 1000;
1032 }
1033 }
1034 }
1035 }
1036
1037 let mut waves: Vec<Wave> = Vec::new();
1039 let mut remaining = in_degree.clone();
1040 let mut wave_number = 1;
1041
1042 while !remaining.is_empty() {
1043 let mut ready: Vec<String> = remaining
1044 .iter()
1045 .filter(|(_, °)| deg == 0)
1046 .map(|(id, _)| id.clone())
1047 .collect();
1048
1049 if ready.is_empty() {
1050 break; }
1052
1053 ready.sort();
1055
1056 let mut wave_tasks: Vec<WaveTask> = ready
1058 .iter()
1059 .filter_map(|task_id| {
1060 actionable.iter().find(|t| &t.id == task_id).map(|task| {
1061 let state = if task.status == TaskStatus::Done {
1062 WaveTaskState::Done
1063 } else if running_task_ids.contains(&task.id) {
1064 WaveTaskState::Running
1065 } else if task.status == TaskStatus::InProgress {
1066 WaveTaskState::InProgress
1067 } else if task.status == TaskStatus::Blocked {
1068 WaveTaskState::Blocked
1069 } else if self.is_task_ready(task, phase) {
1070 WaveTaskState::Ready
1071 } else {
1072 WaveTaskState::Blocked
1073 };
1074
1075 WaveTask {
1076 id: task.id.clone(),
1077 title: task.title.clone(),
1078 tag: self.active_tag.clone().unwrap_or_default(),
1079 state,
1080 complexity: task.complexity,
1081 dependencies: task.dependencies.clone(),
1082 }
1083 })
1084 })
1085 .collect();
1086
1087 for task_id in &ready {
1089 remaining.remove(task_id);
1090 if let Some(deps) = dependents.get(task_id) {
1091 for dep_id in deps {
1092 if let Some(deg) = remaining.get_mut(dep_id) {
1093 *deg = deg.saturating_sub(1);
1094 }
1095 }
1096 }
1097 }
1098
1099 if !wave_tasks.is_empty() {
1100 wave_tasks.sort_by(|a, b| a.id.cmp(&b.id));
1102 waves.push(Wave {
1103 number: wave_number,
1104 tasks: wave_tasks,
1105 });
1106 }
1107 wave_number += 1;
1108 }
1109
1110 waves
1111 }
1112
1113 fn is_task_ready(&self, task: &Task, phase: &Phase) -> bool {
1115 if task.status != TaskStatus::Pending {
1116 return false;
1117 }
1118
1119 for dep_id in &task.dependencies {
1121 if !self.is_dependency_satisfied(dep_id, phase) {
1122 return false;
1123 }
1124 }
1125
1126 true
1127 }
1128
1129 fn is_dependency_satisfied(&self, dep_id: &str, phase: &Phase) -> bool {
1131 let Some(dep) = phase.get_task(dep_id) else {
1132 return true; };
1134
1135 match dep.status {
1136 TaskStatus::Done => true,
1137 TaskStatus::Expanded => {
1138 if dep.subtasks.is_empty() {
1140 false } else {
1142 dep.subtasks.iter().all(|subtask_id| {
1143 phase
1144 .get_task(subtask_id)
1145 .map(|st| st.status == TaskStatus::Done)
1146 .unwrap_or(false)
1147 })
1148 }
1149 }
1150 _ => false, }
1152 }
1153
1154 pub fn all_wave_tasks(&self) -> Vec<&WaveTask> {
1156 self.waves.iter().flat_map(|w| w.tasks.iter()).collect()
1157 }
1158
1159 pub fn selected_wave_task(&self) -> Option<&WaveTask> {
1161 let all_tasks = self.all_wave_tasks();
1162 all_tasks.get(self.wave_task_index).copied()
1163 }
1164
1165 pub fn next_panel(&mut self) {
1169 self.focused_panel = match self.focused_panel {
1170 FocusedPanel::Waves => FocusedPanel::Agents,
1171 FocusedPanel::Agents => FocusedPanel::Output,
1172 FocusedPanel::Output => FocusedPanel::Waves,
1173 };
1174 }
1175
1176 pub fn previous_panel(&mut self) {
1178 self.focused_panel = match self.focused_panel {
1179 FocusedPanel::Waves => FocusedPanel::Output,
1180 FocusedPanel::Agents => FocusedPanel::Waves,
1181 FocusedPanel::Output => FocusedPanel::Agents,
1182 };
1183 }
1184
1185 pub fn move_up(&mut self) {
1187 match self.focused_panel {
1188 FocusedPanel::Waves => {
1189 if self.wave_task_index > 0 {
1190 self.wave_task_index -= 1;
1191 self.adjust_wave_scroll();
1192 }
1193 }
1194 FocusedPanel::Agents => self.previous_agent(),
1195 FocusedPanel::Output => self.scroll_up(1),
1196 }
1197 }
1198
1199 pub fn move_down(&mut self) {
1201 match self.focused_panel {
1202 FocusedPanel::Waves => {
1203 let max = self.all_wave_tasks().len().saturating_sub(1);
1204 if self.wave_task_index < max {
1205 self.wave_task_index += 1;
1206 self.adjust_wave_scroll();
1207 }
1208 }
1209 FocusedPanel::Agents => self.next_agent(),
1210 FocusedPanel::Output => self.scroll_down(1),
1211 }
1212 }
1213
1214 fn adjust_wave_scroll(&mut self) {
1217 let mut line_idx = 0;
1220 let mut found = false;
1221 let mut task_counter = 0;
1222
1223 for wave in &self.waves {
1224 line_idx += 1; for _ in &wave.tasks {
1226 if task_counter == self.wave_task_index {
1227 found = true;
1228 break;
1229 }
1230 line_idx += 1;
1231 task_counter += 1;
1232 }
1233 if found {
1234 break;
1235 }
1236 }
1237
1238 let visible_height = 4;
1240
1241 if line_idx < self.wave_scroll_offset {
1243 self.wave_scroll_offset = line_idx;
1244 } else if line_idx >= self.wave_scroll_offset + visible_height {
1245 self.wave_scroll_offset = line_idx.saturating_sub(visible_height - 1);
1246 }
1247 }
1248
1249 pub fn toggle_task_selection(&mut self) {
1253 if let Some(task) = self.selected_wave_task() {
1254 let task_id = task.id.clone();
1255 if self.selected_tasks.contains(&task_id) {
1256 self.selected_tasks.remove(&task_id);
1257 } else {
1258 if task.state == WaveTaskState::Ready {
1260 self.selected_tasks.insert(task_id);
1261 }
1262 }
1263 }
1264 }
1265
1266 pub fn select_all_ready(&mut self) {
1268 for wave in &self.waves {
1269 for task in &wave.tasks {
1270 if task.state == WaveTaskState::Ready {
1271 self.selected_tasks.insert(task.id.clone());
1272 }
1273 }
1274 }
1275 }
1276
1277 pub fn clear_selection(&mut self) {
1279 self.selected_tasks.clear();
1280 }
1281
1282 pub fn ready_task_count(&self) -> usize {
1284 self.waves
1285 .iter()
1286 .flat_map(|w| &w.tasks)
1287 .filter(|t| t.state == WaveTaskState::Ready)
1288 .count()
1289 }
1290
1291 pub fn selected_task_count(&self) -> usize {
1293 self.selected_tasks.len()
1294 }
1295
1296 pub fn get_selected_tasks(&self) -> Vec<&WaveTask> {
1298 self.all_wave_tasks()
1299 .into_iter()
1300 .filter(|t| self.selected_tasks.contains(&t.id))
1301 .collect()
1302 }
1303
1304 pub fn spawn_selected_tasks(&mut self) -> Result<usize> {
1307 use crate::commands::spawn::{agent, terminal};
1308
1309 let tasks_to_spawn: Vec<(String, String, String)> = self
1310 .get_selected_tasks()
1311 .iter()
1312 .map(|t| (t.id.clone(), t.title.clone(), t.tag.clone()))
1313 .collect();
1314
1315 if tasks_to_spawn.is_empty() {
1316 return Ok(0);
1317 }
1318
1319 let working_dir = self
1321 .project_root
1322 .clone()
1323 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1324
1325 let session = match &self.session {
1327 Some(s) => s,
1328 None => {
1329 self.error = Some("No session loaded".to_string());
1330 return Ok(0);
1331 }
1332 };
1333
1334 let session_name = session.session_name.clone();
1335 let mut spawned_count = 0;
1336
1337 let storage = Storage::new(self.project_root.clone());
1339
1340 for (task_id, task_title, tag) in &tasks_to_spawn {
1341 let phase = match self.phases.get(tag) {
1343 Some(p) => p,
1344 None => continue,
1345 };
1346
1347 let task = match phase.get_task(task_id) {
1348 Some(t) => t,
1349 None => continue,
1350 };
1351
1352 let prompt = agent::generate_prompt(task, tag);
1354
1355 match terminal::spawn_terminal(task_id, &prompt, &working_dir, &session_name) {
1357 Ok(_window_index) => {
1358 spawned_count += 1;
1359
1360 if let Some(ref mut session) = self.session {
1362 session.add_agent(task_id, task_title, tag);
1363 }
1364
1365 if let Ok(mut phase) = storage.load_group(tag) {
1367 if let Some(task) = phase.get_task_mut(task_id) {
1368 task.set_status(TaskStatus::InProgress);
1369 let _ = storage.update_group(tag, &phase);
1370 }
1371 }
1372 }
1373 Err(e) => {
1374 self.error = Some(format!("Failed to spawn {}: {}", task_id, e));
1375 }
1376 }
1377
1378 if spawned_count < tasks_to_spawn.len() {
1380 std::thread::sleep(Duration::from_millis(300));
1381 }
1382 }
1383
1384 if spawned_count > 0 {
1386 if let Some(ref session) = self.session {
1387 let _ = crate::commands::spawn::monitor::save_session(
1388 self.project_root.as_ref(),
1389 session,
1390 );
1391 }
1392
1393 self.selected_tasks.clear();
1395 self.refresh()?;
1396 self.refresh_waves();
1397 }
1398
1399 Ok(spawned_count)
1400 }
1401
1402 pub fn prepare_swarm_start(&self) -> Option<(String, String)> {
1404 let tag = self
1406 .session
1407 .as_ref()
1408 .map(|s| s.tag.clone())
1409 .or_else(|| self.active_tag.clone())?;
1410
1411 let session_base = self.session_name.replace("swarm-", "").replace("scud-", "");
1413 let cmd = format!("scud swarm --tag {} --session {}", tag, session_base);
1414
1415 Some((cmd, tag))
1416 }
1417
1418 pub fn set_selected_task_status(&mut self, new_status: TaskStatus) -> Result<()> {
1420 let Some(ref session) = self.session else {
1421 self.error = Some("No session loaded".to_string());
1422 return Ok(());
1423 };
1424
1425 let agents = session.agents.clone();
1426 if agents.is_empty() || self.selected >= agents.len() {
1427 self.error = Some("No agent selected".to_string());
1428 return Ok(());
1429 }
1430
1431 let agent = &agents[self.selected];
1432 let task_id = &agent.task_id;
1433 let tag = &agent.tag;
1434
1435 let storage = Storage::new(self.project_root.clone());
1437 if let Ok(mut phase) = storage.load_group(tag) {
1438 if let Some(task) = phase.get_task_mut(task_id) {
1439 task.set_status(new_status.clone());
1440 if let Err(e) = storage.update_group(tag, &phase) {
1441 self.error = Some(format!("Failed to save: {}", e));
1442 return Ok(());
1443 }
1444 self.error = Some(format!("✓ {} → {}", task_id, new_status.as_str()));
1446 } else {
1447 self.error = Some(format!("Task {} not found", task_id));
1448 }
1449 } else {
1450 self.error = Some(format!("Failed to load phase {}", tag));
1451 }
1452
1453 self.refresh()?;
1455 self.refresh_waves();
1456
1457 Ok(())
1458 }
1459
1460 fn spawn_task_with_ralph(&mut self, task_id: &str) -> Result<()> {
1463 use crate::commands::spawn::{agent, terminal};
1464
1465 let task_info = self
1467 .waves
1468 .iter()
1469 .flat_map(|w| w.tasks.iter())
1470 .find(|t| t.id == task_id)
1471 .map(|t| (t.id.clone(), t.title.clone(), t.tag.clone()));
1472
1473 let (task_id, task_title, tag) = match task_info {
1474 Some(info) => info,
1475 None => return Ok(()),
1476 };
1477
1478 let working_dir = self
1480 .project_root
1481 .clone()
1482 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1483
1484 let session = match &self.session {
1486 Some(s) => s,
1487 None => {
1488 self.error = Some("No session loaded".to_string());
1489 return Ok(());
1490 }
1491 };
1492
1493 let session_name = session.session_name.clone();
1494
1495 let storage = Storage::new(self.project_root.clone());
1497
1498 let phase = match self.phases.get(&tag) {
1499 Some(p) => p,
1500 None => return Ok(()),
1501 };
1502
1503 let task = match phase.get_task(&task_id) {
1504 Some(t) => t,
1505 None => return Ok(()),
1506 };
1507
1508 let base_prompt = agent::generate_prompt(task, &tag);
1510 let ralph_prompt = format!(
1511 r#"{}
1512
1513═══════════════════════════════════════════════════════════
1514RALPH LOOP MODE - Autonomous Task Completion
1515═══════════════════════════════════════════════════════════
1516
1517CRITICAL: Your task ID is **{task_id}** (NOT any parent task!)
1518
1519You are in a Ralph loop. Keep working until the task is COMPLETE.
1520
1521After EACH attempt:
15221. Run EXACTLY: scud set-status {task_id} done
1523 ⚠️ Use task ID "{task_id}" - do NOT use any other task ID!
15242. Verify the task is truly done (tests pass, code works)
15253. If something failed, fix it and try again
1526
1527The loop will continue until task {task_id} is marked done.
1528Do NOT give up. Keep iterating until success.
1529
1530When you have genuinely completed task {task_id}, output:
1531<promise>TASK {task_id} COMPLETE</promise>
1532
1533DO NOT output this promise unless task {task_id} is TRULY complete!
1534═══════════════════════════════════════════════════════════
1535"#,
1536 base_prompt,
1537 task_id = task_id
1538 );
1539
1540 match terminal::spawn_terminal_ralph(
1542 &task_id,
1543 &ralph_prompt,
1544 &working_dir,
1545 &session_name,
1546 &format!("TASK {} COMPLETE", task_id),
1547 ) {
1548 Ok(()) => {
1549 if let Some(ref mut session) = self.session {
1551 session.add_agent(&task_id, &task_title, &tag);
1552 }
1553
1554 if let Ok(mut phase) = storage.load_group(&tag) {
1556 if let Some(task) = phase.get_task_mut(&task_id) {
1557 task.set_status(TaskStatus::InProgress);
1558 let _ = storage.update_group(&tag, &phase);
1559 }
1560 }
1561
1562 if let Some(ref session) = self.session {
1564 let _ = crate::commands::spawn::monitor::save_session(
1565 self.project_root.as_ref(),
1566 session,
1567 );
1568 }
1569
1570 let _ = self.refresh();
1572 self.refresh_waves();
1573 }
1574 Err(e) => {
1575 self.error = Some(format!(
1576 "Failed to spawn Ralph agent for {}: {}",
1577 task_id, e
1578 ));
1579 }
1580 }
1581
1582 Ok(())
1583 }
1584}