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 #[cfg(feature = "socket-feed")]
242 pub fn set_feed_handle(&mut self, handle: Option<FeedHandleSync>) {
243 self.feed_handle = handle;
244 }
245
246 #[cfg(not(feature = "socket-feed"))]
248 pub fn set_feed_handle(&mut self, _handle: Option<()>) {
249 }
251
252 #[cfg(feature = "socket-feed")]
254 pub fn has_feed(&self) -> bool {
255 self.feed_handle.is_some()
256 }
257
258 #[cfg(not(feature = "socket-feed"))]
260 pub fn has_feed(&self) -> bool {
261 false
262 }
263
264 #[cfg(feature = "socket-feed")]
266 pub fn publish_session_snapshot(&self) {
267 if let (Some(handle), Some(session)) = (&self.feed_handle, &self.session) {
268 let snapshot = session_to_snapshot(session);
269 handle.publish_session(snapshot);
270 }
271 }
272
273 #[cfg(not(feature = "socket-feed"))]
275 pub fn publish_session_snapshot(&self) {
276 }
278
279 #[cfg(feature = "socket-feed")]
281 pub fn publish_output(&self) {
282 if let Some(handle) = &self.feed_handle {
283 if let Some(agent) = self.selected_agent() {
284 let output = create_output_message(&agent.task_id, self.live_output.clone());
285 handle.try_publish_output(output);
286 }
287 }
288 }
289
290 #[cfg(not(feature = "socket-feed"))]
292 pub fn publish_output(&self) {
293 }
295
296 #[cfg(feature = "socket-feed")]
298 pub fn publish_wave_update(&self) {
299 if let Some(handle) = &self.feed_handle {
300 let waves: Vec<WaveSnapshot> = self
301 .waves
302 .iter()
303 .map(|w| WaveSnapshot {
304 number: w.number,
305 tasks: w
306 .tasks
307 .iter()
308 .map(|t| TaskSnapshot {
309 id: t.id.clone(),
310 title: t.title.clone(),
311 state: format!("{:?}", t.state).to_lowercase(),
312 complexity: t.complexity,
313 })
314 .collect(),
315 })
316 .collect();
317
318 let (ready, running, done, blocked) = self.count_wave_states();
319
320 let update = WaveUpdate {
321 waves,
322 ready_count: ready,
323 running_count: running,
324 done_count: done,
325 blocked_count: blocked,
326 timestamp: chrono::Utc::now().to_rfc3339(),
327 };
328
329 handle.publish_wave_update(update);
330 }
331 }
332
333 #[cfg(not(feature = "socket-feed"))]
335 pub fn publish_wave_update(&self) {
336 }
338
339 #[cfg(feature = "socket-feed")]
341 pub fn publish_stats(&self) {
342 if let (Some(handle), Some(session)) = (&self.feed_handle, &self.session) {
343 let stats = SpawnStats::from(session);
344 let snapshot = StatsSnapshot::from(&stats);
345 handle.publish_stats(snapshot);
346 }
347 }
348
349 #[cfg(not(feature = "socket-feed"))]
351 pub fn publish_stats(&self) {
352 }
354
355 #[cfg(feature = "socket-feed")]
357 pub fn publish_agent_changes(&mut self) {
358 let updates: Vec<_> = if self.feed_handle.is_some() {
360 self.agents()
361 .iter()
362 .filter_map(|agent| {
363 let prev = self.previous_agent_statuses.get(&agent.task_id);
364 if prev != Some(&agent.status) {
365 Some(create_agent_update(
366 &agent.task_id,
367 &agent.status,
368 prev,
369 ))
370 } else {
371 None
372 }
373 })
374 .collect()
375 } else {
376 Vec::new()
377 };
378
379 if let Some(handle) = &self.feed_handle {
381 for update in updates {
382 handle.publish_agent_update(update);
383 }
384 }
385
386 let new_statuses: Vec<_> = self
388 .agents()
389 .iter()
390 .map(|a| (a.task_id.clone(), a.status.clone()))
391 .collect();
392
393 self.previous_agent_statuses.clear();
395 for (id, status) in new_statuses {
396 self.previous_agent_statuses.insert(id, status);
397 }
398 }
399
400 #[cfg(not(feature = "socket-feed"))]
402 pub fn publish_agent_changes(&mut self) {
403 }
405
406 pub fn shutdown_feed(&self) {
408 }
411
412 #[cfg(feature = "socket-feed")]
414 fn count_wave_states(&self) -> (usize, usize, usize, usize) {
415 let mut ready = 0;
416 let mut running = 0;
417 let mut done = 0;
418 let mut blocked = 0;
419
420 for wave in &self.waves {
421 for task in &wave.tasks {
422 match task.state {
423 WaveTaskState::Ready => ready += 1,
424 WaveTaskState::Running | WaveTaskState::InProgress => running += 1,
425 WaveTaskState::Done => done += 1,
426 WaveTaskState::Blocked => blocked += 1,
427 }
428 }
429 }
430
431 (ready, running, done, blocked)
432 }
433
434 pub fn refresh(&mut self) -> Result<()> {
436 if self.swarm_mode {
437 match swarm_session::load_session(self.project_root.as_ref(), &self.session_name) {
439 Ok(session) => {
440 self.swarm_session_data = Some(session);
441 self.error = None;
442 self.swarm_progress = self.compute_swarm_progress();
444 }
445 Err(e) => {
446 self.error = Some(format!("Failed to load swarm session: {}", e));
447 self.swarm_progress = None;
448 }
449 }
450 } else {
451 match load_session(self.project_root.as_ref(), &self.session_name) {
453 Ok(mut session) => {
454 self.refresh_agent_statuses(&mut session);
456
457 let _ = save_session(self.project_root.as_ref(), &session);
459
460 self.session = Some(session);
461 self.error = None;
462 }
463 Err(e) => {
464 self.error = Some(format!("Failed to load session: {}", e));
465 }
466 }
467 }
468 self.last_refresh = Instant::now();
469 Ok(())
470 }
471
472 pub fn refresh_live_output(&mut self) {
476 if let Some(ref store) = self.stream_store {
478 let agents = self.agents();
479 if agents.is_empty() || self.selected >= agents.len() {
480 self.live_output = vec!["No agent selected".to_string()];
481 return;
482 }
483
484 let agent = &agents[self.selected];
485 self.live_output = store.get_output(&agent.task_id, 100);
486
487 if self.live_output.is_empty() {
488 self.live_output = vec!["Waiting for output...".to_string()];
489 }
490
491 self.last_output_refresh = Instant::now();
492 return;
493 }
494
495 let agents = self.agents();
497 if agents.is_empty() || self.selected >= agents.len() {
498 self.live_output = vec!["No agent selected".to_string()];
499 return;
500 }
501
502 let agent = &agents[self.selected];
503 let session = match &self.session {
504 Some(s) => s,
505 None => {
506 self.live_output = vec!["No session loaded".to_string()];
507 return;
508 }
509 };
510
511 let tmux_windows = self.get_tmux_windows(&session.session_name);
513 let window_target = match self.window_target_for(
514 &session.session_name,
515 &agent.window_name,
516 &tmux_windows,
517 ) {
518 Some(target) => target,
519 None => {
520 self.live_output = vec![format!("Window '{}' not found", agent.window_name)];
521 return;
522 }
523 };
524
525 let output = Command::new("tmux")
527 .args([
528 "capture-pane",
529 "-t",
530 &window_target,
531 "-p", "-S",
533 "-100", ])
535 .output();
536
537 match output {
538 Ok(out) if out.status.success() => {
539 let content = String::from_utf8_lossy(&out.stdout);
540 self.live_output = content.lines().map(|s| s.to_string()).collect();
541
542 while self
544 .live_output
545 .last()
546 .map(|s| s.trim().is_empty())
547 .unwrap_or(false)
548 {
549 self.live_output.pop();
550 }
551 }
552 Ok(out) => {
553 self.live_output = vec![format!("Error: {}", String::from_utf8_lossy(&out.stderr))];
554 }
555 Err(e) => {
556 self.live_output = vec![format!("tmux error: {}", e)];
557 }
558 }
559
560 self.last_output_refresh = Instant::now();
561 }
562
563 fn refresh_agent_statuses(&self, session: &mut SpawnSession) {
565 let tmux_windows = self.get_tmux_windows(&session.session_name);
566 let storage = Storage::new(self.project_root.clone());
567 let all_phases = storage.load_tasks().ok();
568
569 for agent in &mut session.agents {
570 let window_exists = self
571 .find_window_index(&agent.window_name, &tmux_windows)
572 .is_some();
573
574 let task_status = all_phases.as_ref().and_then(|phases| {
575 phases.get(&agent.tag).and_then(|phase| {
576 phase
577 .get_task(&agent.task_id)
578 .map(|task| task.status.clone())
579 })
580 });
581
582 agent.status = match (&task_status, window_exists) {
583 (Some(TaskStatus::Done), _) => AgentStatus::Completed,
584 (Some(TaskStatus::Blocked), _) => AgentStatus::Failed,
585 (Some(TaskStatus::InProgress), true) => AgentStatus::Running,
586 (Some(TaskStatus::InProgress), false) => AgentStatus::Failed,
588 (_, false) => AgentStatus::Completed,
589 (_, true) => AgentStatus::Running,
590 };
591 }
592 }
593
594 fn get_tmux_windows(&self, session_name: &str) -> Vec<(usize, String)> {
596 let output = Command::new("tmux")
597 .args([
598 "list-windows",
599 "-t",
600 session_name,
601 "-F",
602 "#{window_index}:#{window_name}",
603 ])
604 .output();
605
606 match output {
607 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
608 .lines()
609 .filter_map(|line| {
610 let parts: Vec<&str> = line.splitn(2, ':').collect();
611 if parts.len() == 2 {
612 parts[0].parse().ok().map(|idx| (idx, parts[1].to_string()))
613 } else {
614 None
615 }
616 })
617 .collect(),
618 _ => Vec::new(),
619 }
620 }
621
622 fn window_name_matches(expected: &str, observed: &str) -> bool {
623 observed.starts_with(expected) || expected.starts_with(observed)
624 }
625
626 fn find_window_index(
627 &self,
628 window_name: &str,
629 tmux_windows: &[(usize, String)],
630 ) -> Option<usize> {
631 tmux_windows
632 .iter()
633 .find(|(_, observed_name)| Self::window_name_matches(window_name, observed_name))
634 .map(|(index, _)| *index)
635 }
636
637 fn window_target_for(
638 &self,
639 session_name: &str,
640 window_name: &str,
641 tmux_windows: &[(usize, String)],
642 ) -> Option<String> {
643 self.find_window_index(window_name, tmux_windows)
644 .map(|index| format!("{}:{}", session_name, index))
645 }
646
647 pub fn tick(&mut self) -> Result<()> {
649 if self.last_refresh.elapsed() >= self.refresh_interval {
651 self.refresh()?;
652 self.refresh_waves();
653
654 if self.has_feed() {
656 self.publish_agent_changes();
657 self.publish_wave_update();
658 self.publish_stats();
659 }
660 }
661
662 if self.last_output_refresh.elapsed() >= self.output_refresh_interval {
664 self.refresh_live_output();
665
666 if self.has_feed() {
668 self.publish_output();
669 }
670 }
671
672 #[cfg(feature = "socket-feed")]
674 if self.has_feed() && self.last_feed_publish.elapsed() >= Duration::from_secs(5) {
675 self.publish_session_snapshot();
676 self.last_feed_publish = Instant::now();
677 }
678
679 if self.ralph_mode && self.last_ralph_check.elapsed() >= Duration::from_secs(5) {
681 self.ralph_auto_spawn();
682 self.last_ralph_check = Instant::now();
683 }
684
685 Ok(())
686 }
687
688 pub fn toggle_ralph_mode(&mut self) {
690 self.ralph_mode = !self.ralph_mode;
691 if self.ralph_mode {
692 self.ralph_auto_spawn();
694 }
695 }
696
697 fn ralph_auto_spawn(&mut self) {
699 let running_count = self
701 .agents()
702 .iter()
703 .filter(|a| a.status == AgentStatus::Running || a.status == AgentStatus::Starting)
704 .count();
705
706 if running_count >= self.ralph_max_parallel {
707 return; }
709
710 let slots_available = self.ralph_max_parallel - running_count;
712 let mut tasks_to_spawn: Vec<String> = Vec::new();
713
714 for wave in &self.waves {
715 for task in &wave.tasks {
716 if task.state == WaveTaskState::Ready && !self.selected_tasks.contains(&task.id) {
717 let already_spawned = self.agents().iter().any(|a| a.task_id == task.id);
719 if !already_spawned {
720 tasks_to_spawn.push(task.id.clone());
721 if tasks_to_spawn.len() >= slots_available {
722 break;
723 }
724 }
725 }
726 }
727 if tasks_to_spawn.len() >= slots_available {
728 break;
729 }
730 }
731
732 for task_id in tasks_to_spawn {
734 let _ = self.spawn_task_with_ralph(&task_id);
735 }
736 }
737
738 pub fn agents(&self) -> &[AgentState] {
740 self.session
741 .as_ref()
742 .map(|s| s.agents.as_slice())
743 .unwrap_or(&[])
744 }
745
746 pub fn next_agent(&mut self) {
748 let len = self.agents().len();
749 if len > 0 {
750 self.selected = (self.selected + 1) % len;
751 self.adjust_agents_scroll();
752 self.reset_scroll();
753 self.refresh_live_output();
754 }
755 }
756
757 pub fn previous_agent(&mut self) {
759 let len = self.agents().len();
760 if len > 0 {
761 self.selected = if self.selected > 0 {
762 self.selected - 1
763 } else {
764 len - 1
765 };
766 self.adjust_agents_scroll();
767 self.reset_scroll();
768 self.refresh_live_output();
769 }
770 }
771
772 pub fn adjust_agents_scroll(&mut self) {
775 const VISIBLE_LINES: usize = 8;
776
777 if self.selected < self.agents_scroll_offset {
779 self.agents_scroll_offset = self.selected;
780 }
781 else if self.selected >= self.agents_scroll_offset + VISIBLE_LINES {
783 self.agents_scroll_offset = self.selected.saturating_sub(VISIBLE_LINES - 1);
784 }
785 }
786
787 pub fn toggle_fullscreen(&mut self) {
789 self.view_mode = match self.view_mode {
790 ViewMode::Split => ViewMode::Fullscreen,
791 ViewMode::Fullscreen => ViewMode::Split,
792 ViewMode::Input => ViewMode::Fullscreen,
793 };
794 }
795
796 pub fn exit_fullscreen(&mut self) {
798 self.view_mode = ViewMode::Split;
799 self.input_buffer.clear();
800 }
801
802 pub fn enter_input_mode(&mut self) {
804 self.view_mode = ViewMode::Input;
805 self.input_buffer.clear();
806 }
807
808 pub fn input_char(&mut self, c: char) {
810 self.input_buffer.push(c);
811 }
812
813 pub fn input_backspace(&mut self) {
815 self.input_buffer.pop();
816 }
817
818 pub fn send_input(&mut self) -> Result<()> {
820 if self.input_buffer.is_empty() {
821 return Ok(());
822 }
823
824 let session = match &self.session {
825 Some(s) => s,
826 None => {
827 self.error = Some("No session loaded".to_string());
828 return Ok(());
829 }
830 };
831
832 let agents = self.agents();
833 if agents.is_empty() || self.selected >= agents.len() {
834 self.error = Some("No agent selected".to_string());
835 return Ok(());
836 }
837
838 let agent = &agents[self.selected];
839
840 let tmux_windows = self.get_tmux_windows(&session.session_name);
842 let window_target = match self.window_target_for(
843 &session.session_name,
844 &agent.window_name,
845 &tmux_windows,
846 ) {
847 Some(target) => target,
848 None => {
849 self.error = Some(format!("Window not found for {}", agent.task_id));
850 return Ok(());
851 }
852 };
853
854 let result = Command::new("tmux")
856 .args([
857 "send-keys",
858 "-t",
859 &window_target,
860 &self.input_buffer,
861 "Enter",
862 ])
863 .output();
864
865 match result {
866 Ok(out) if out.status.success() => {
867 self.error = None;
868 self.input_buffer.clear();
869 self.view_mode = ViewMode::Fullscreen; self.refresh_live_output();
871 }
872 Ok(out) => {
873 self.error = Some(format!(
874 "Send failed: {}",
875 String::from_utf8_lossy(&out.stderr)
876 ));
877 }
878 Err(e) => {
879 self.error = Some(format!("tmux error: {}", e));
880 }
881 }
882
883 Ok(())
884 }
885
886 pub fn restart_agent(&mut self) -> Result<()> {
888 let session = match &self.session {
889 Some(s) => s,
890 None => return Ok(()),
891 };
892
893 let agents = self.agents();
894 if agents.is_empty() || self.selected >= agents.len() {
895 return Ok(());
896 }
897
898 let agent = &agents[self.selected];
899
900 let tmux_windows = self.get_tmux_windows(&session.session_name);
902 if let Some(target) =
903 self.window_target_for(&session.session_name, &agent.window_name, &tmux_windows)
904 {
905 let _ = Command::new("tmux")
907 .args(["send-keys", "-t", &target, "C-c"])
908 .output();
909
910 std::thread::sleep(Duration::from_millis(200));
912
913 let _ = Command::new("tmux")
915 .args([
916 "send-keys",
917 "-t",
918 &target,
919 "echo 'Agent restarted by user'",
920 "Enter",
921 ])
922 .output();
923
924 self.error = None;
925 self.refresh_live_output();
926 }
927
928 Ok(())
929 }
930
931 pub fn toggle_help(&mut self) {
933 self.show_help = !self.show_help;
934 }
935
936 pub fn scroll_up(&mut self, lines: usize) {
938 let max_scroll = self.live_output.len().saturating_sub(1);
939 self.scroll_offset = (self.scroll_offset + lines).min(max_scroll);
940 self.auto_scroll = false;
941 }
942
943 pub fn scroll_down(&mut self, lines: usize) {
945 self.scroll_offset = self.scroll_offset.saturating_sub(lines);
946 if self.scroll_offset == 0 {
947 self.auto_scroll = true;
948 }
949 }
950
951 pub fn scroll_to_bottom(&mut self) {
953 self.scroll_offset = 0;
954 self.auto_scroll = true;
955 }
956
957 fn reset_scroll(&mut self) {
959 self.scroll_offset = 0;
960 self.auto_scroll = true;
961 }
962
963 pub fn status_counts(&self) -> (usize, usize, usize, usize) {
965 let agents = self.agents();
966 let starting = agents
967 .iter()
968 .filter(|a| a.status == AgentStatus::Starting)
969 .count();
970 let running = agents
971 .iter()
972 .filter(|a| a.status == AgentStatus::Running)
973 .count();
974 let completed = agents
975 .iter()
976 .filter(|a| a.status == AgentStatus::Completed)
977 .count();
978 let failed = agents
979 .iter()
980 .filter(|a| a.status == AgentStatus::Failed)
981 .count();
982 (starting, running, completed, failed)
983 }
984
985 pub fn selected_agent(&self) -> Option<&AgentState> {
987 let agents = self.agents();
988 if agents.is_empty() || self.selected >= agents.len() {
989 None
990 } else {
991 Some(&agents[self.selected])
992 }
993 }
994
995 pub fn refresh_waves(&mut self) {
999 let storage = Storage::new(self.project_root.clone());
1001 self.phases = storage.load_tasks().unwrap_or_default();
1002
1003 if self.swarm_mode {
1005 self.waves = self.compute_swarm_waves();
1006 self.swarm_progress = self.compute_swarm_progress();
1008 return;
1009 }
1010
1011 let running_task_ids: HashSet<String> = self
1013 .agents()
1014 .iter()
1015 .filter(|a| a.status == AgentStatus::Running || a.status == AgentStatus::Starting)
1016 .map(|a| a.task_id.clone())
1017 .collect();
1018
1019 let tag = self.active_tag.clone().or_else(|| {
1021 self.session.as_ref().map(|s| s.tag.clone())
1023 });
1024
1025 let Some(tag) = tag else {
1026 self.waves = Vec::new();
1027 return;
1028 };
1029
1030 let Some(phase) = self.phases.get(&tag) else {
1031 self.waves = Vec::new();
1032 return;
1033 };
1034
1035 self.waves = self.compute_waves(phase, &running_task_ids);
1037 }
1038
1039 fn compute_swarm_progress(&self) -> Option<SwarmProgress> {
1044 let swarm = self.swarm_session_data.as_ref()?;
1045 let phase = self.phases.get(&swarm.tag);
1046
1047 let total_waves = self.waves.len();
1048
1049 let current_wave = swarm
1051 .waves
1052 .iter()
1053 .find(|w| w.completed_at.is_none())
1054 .map(|w| w.wave_number)
1055 .unwrap_or_else(|| swarm.waves.last().map(|w| w.wave_number).unwrap_or(0));
1056
1057 let (tasks_completed, tasks_in_progress, tasks_failed, tasks_total) =
1059 if let Some(phase) = phase {
1060 let swarm_task_ids: HashSet<String> =
1062 swarm.waves.iter().flat_map(|w| w.all_task_ids()).collect();
1063
1064 let mut completed = 0;
1065 let mut in_progress = 0;
1066 let mut failed = 0;
1067 let total = swarm_task_ids.len();
1068
1069 for task_id in &swarm_task_ids {
1070 if let Some(task) = phase.get_task(task_id) {
1071 match task.status {
1072 TaskStatus::Done => completed += 1,
1073 TaskStatus::InProgress => in_progress += 1,
1074 TaskStatus::Blocked => failed += 1,
1075 _ => {}
1076 }
1077 }
1078 }
1079
1080 (completed, in_progress, failed, total)
1081 } else {
1082 let total = swarm.total_tasks();
1084 let failed = swarm.total_failures();
1085 (0, 0, failed, total)
1086 };
1087
1088 let (waves_validated, waves_failed_validation) =
1090 swarm
1091 .waves
1092 .iter()
1093 .fold((0, 0), |(validated, failed), wave| match &wave.validation {
1094 Some(v) if v.all_passed => (validated + 1, failed),
1095 Some(_) => (validated, failed + 1),
1096 None => (validated, failed),
1097 });
1098
1099 let total_repairs: usize = swarm.waves.iter().map(|w| w.repairs.len()).sum();
1101
1102 Some(SwarmProgress {
1103 current_wave,
1104 total_waves,
1105 tasks_completed,
1106 tasks_total,
1107 tasks_in_progress,
1108 tasks_failed,
1109 waves_validated,
1110 waves_failed_validation,
1111 total_repairs,
1112 })
1113 }
1114
1115 fn compute_swarm_waves(&self) -> Vec<Wave> {
1117 let Some(ref swarm) = self.swarm_session_data else {
1118 return Vec::new();
1119 };
1120
1121 let tag = &swarm.tag;
1122 let phase = self.phases.get(tag);
1123
1124 swarm
1125 .waves
1126 .iter()
1127 .map(|wave_state| {
1128 let task_ids: Vec<String> = wave_state
1130 .rounds
1131 .iter()
1132 .flat_map(|round| round.task_ids.iter().cloned())
1133 .collect();
1134
1135 let tasks: Vec<WaveTask> = task_ids
1136 .iter()
1137 .map(|task_id| {
1138 let (title, complexity, dependencies, task_status) =
1140 if let Some(phase) = phase {
1141 if let Some(task) = phase.get_task(task_id) {
1142 (
1143 task.title.clone(),
1144 task.complexity,
1145 task.dependencies.clone(),
1146 Some(task.status.clone()),
1147 )
1148 } else {
1149 (task_id.clone(), 1, vec![], None)
1150 }
1151 } else {
1152 (task_id.clone(), 1, vec![], None)
1153 };
1154
1155 let state = match task_status {
1157 Some(TaskStatus::Done) => WaveTaskState::Done,
1158 Some(TaskStatus::InProgress) => WaveTaskState::Running,
1159 Some(TaskStatus::Blocked) => WaveTaskState::Blocked,
1160 Some(TaskStatus::Pending) => {
1161 if wave_state.completed_at.is_some() {
1162 WaveTaskState::Blocked
1164 } else {
1165 WaveTaskState::Ready
1166 }
1167 }
1168 _ => WaveTaskState::Ready,
1169 };
1170
1171 WaveTask {
1172 id: task_id.clone(),
1173 title,
1174 tag: tag.clone(),
1175 state,
1176 complexity,
1177 dependencies,
1178 }
1179 })
1180 .collect();
1181
1182 Wave {
1183 number: wave_state.wave_number,
1184 tasks,
1185 }
1186 })
1187 .collect()
1188 }
1189
1190 fn compute_waves(&self, phase: &Phase, running_task_ids: &HashSet<String>) -> Vec<Wave> {
1192 let mut actionable: Vec<&Task> = Vec::new();
1194 for task in &phase.tasks {
1195 if task.status == TaskStatus::Done
1196 || task.status == TaskStatus::Expanded
1197 || task.status == TaskStatus::Cancelled
1198 {
1199 continue;
1200 }
1201
1202 if !task.subtasks.is_empty() {
1204 continue;
1205 }
1206
1207 if let Some(ref parent_id) = task.parent_id {
1209 let parent_expanded = phase
1210 .get_task(parent_id)
1211 .map(|p| p.is_expanded())
1212 .unwrap_or(false);
1213 if !parent_expanded {
1214 continue;
1215 }
1216 }
1217
1218 actionable.push(task);
1219 }
1220
1221 if actionable.is_empty() {
1222 return Vec::new();
1223 }
1224
1225 let task_ids: HashSet<String> = actionable.iter().map(|t| t.id.clone()).collect();
1227 let mut in_degree: HashMap<String, usize> = HashMap::new();
1228 let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
1229
1230 for task in &actionable {
1231 in_degree.entry(task.id.clone()).or_insert(0);
1232
1233 for dep in &task.dependencies {
1234 if task_ids.contains(dep) {
1235 *in_degree.entry(task.id.clone()).or_insert(0) += 1;
1237 dependents
1238 .entry(dep.clone())
1239 .or_default()
1240 .push(task.id.clone());
1241 } else {
1242 if !self.is_dependency_satisfied(dep, phase) {
1245 *in_degree.entry(task.id.clone()).or_insert(0) += 1000;
1247 }
1248 }
1249 }
1250 }
1251
1252 let mut waves: Vec<Wave> = Vec::new();
1254 let mut remaining = in_degree.clone();
1255 let mut wave_number = 1;
1256
1257 while !remaining.is_empty() {
1258 let mut ready: Vec<String> = remaining
1259 .iter()
1260 .filter(|(_, °)| deg == 0)
1261 .map(|(id, _)| id.clone())
1262 .collect();
1263
1264 if ready.is_empty() {
1265 break; }
1267
1268 ready.sort();
1270
1271 let mut wave_tasks: Vec<WaveTask> = ready
1273 .iter()
1274 .filter_map(|task_id| {
1275 actionable.iter().find(|t| &t.id == task_id).map(|task| {
1276 let state = if task.status == TaskStatus::Done {
1277 WaveTaskState::Done
1278 } else if running_task_ids.contains(&task.id) {
1279 WaveTaskState::Running
1280 } else if task.status == TaskStatus::InProgress {
1281 WaveTaskState::InProgress
1282 } else if task.status == TaskStatus::Blocked {
1283 WaveTaskState::Blocked
1284 } else if self.is_task_ready(task, phase) {
1285 WaveTaskState::Ready
1286 } else {
1287 WaveTaskState::Blocked
1288 };
1289
1290 WaveTask {
1291 id: task.id.clone(),
1292 title: task.title.clone(),
1293 tag: self.active_tag.clone().unwrap_or_default(),
1294 state,
1295 complexity: task.complexity,
1296 dependencies: task.dependencies.clone(),
1297 }
1298 })
1299 })
1300 .collect();
1301
1302 for task_id in &ready {
1304 remaining.remove(task_id);
1305 if let Some(deps) = dependents.get(task_id) {
1306 for dep_id in deps {
1307 if let Some(deg) = remaining.get_mut(dep_id) {
1308 *deg = deg.saturating_sub(1);
1309 }
1310 }
1311 }
1312 }
1313
1314 if !wave_tasks.is_empty() {
1315 wave_tasks.sort_by(|a, b| a.id.cmp(&b.id));
1317 waves.push(Wave {
1318 number: wave_number,
1319 tasks: wave_tasks,
1320 });
1321 }
1322 wave_number += 1;
1323 }
1324
1325 waves
1326 }
1327
1328 fn is_task_ready(&self, task: &Task, phase: &Phase) -> bool {
1330 if task.status != TaskStatus::Pending {
1331 return false;
1332 }
1333
1334 for dep_id in &task.dependencies {
1336 if !self.is_dependency_satisfied(dep_id, phase) {
1337 return false;
1338 }
1339 }
1340
1341 true
1342 }
1343
1344 fn is_dependency_satisfied(&self, dep_id: &str, phase: &Phase) -> bool {
1346 let Some(dep) = phase.get_task(dep_id) else {
1347 return true; };
1349
1350 match dep.status {
1351 TaskStatus::Done => true,
1352 TaskStatus::Expanded => {
1353 if dep.subtasks.is_empty() {
1355 false } else {
1357 dep.subtasks.iter().all(|subtask_id| {
1358 phase
1359 .get_task(subtask_id)
1360 .map(|st| st.status == TaskStatus::Done)
1361 .unwrap_or(false)
1362 })
1363 }
1364 }
1365 _ => false, }
1367 }
1368
1369 pub fn all_wave_tasks(&self) -> Vec<&WaveTask> {
1371 self.waves.iter().flat_map(|w| w.tasks.iter()).collect()
1372 }
1373
1374 pub fn selected_wave_task(&self) -> Option<&WaveTask> {
1376 let all_tasks = self.all_wave_tasks();
1377 all_tasks.get(self.wave_task_index).copied()
1378 }
1379
1380 pub fn next_panel(&mut self) {
1384 self.focused_panel = match self.focused_panel {
1385 FocusedPanel::Waves => FocusedPanel::Agents,
1386 FocusedPanel::Agents => FocusedPanel::Output,
1387 FocusedPanel::Output => FocusedPanel::Waves,
1388 };
1389 }
1390
1391 pub fn previous_panel(&mut self) {
1393 self.focused_panel = match self.focused_panel {
1394 FocusedPanel::Waves => FocusedPanel::Output,
1395 FocusedPanel::Agents => FocusedPanel::Waves,
1396 FocusedPanel::Output => FocusedPanel::Agents,
1397 };
1398 }
1399
1400 pub fn move_up(&mut self) {
1402 match self.focused_panel {
1403 FocusedPanel::Waves => {
1404 if self.wave_task_index > 0 {
1405 self.wave_task_index -= 1;
1406 self.adjust_wave_scroll();
1407 }
1408 }
1409 FocusedPanel::Agents => self.previous_agent(),
1410 FocusedPanel::Output => self.scroll_up(1),
1411 }
1412 }
1413
1414 pub fn move_down(&mut self) {
1416 match self.focused_panel {
1417 FocusedPanel::Waves => {
1418 let max = self.all_wave_tasks().len().saturating_sub(1);
1419 if self.wave_task_index < max {
1420 self.wave_task_index += 1;
1421 self.adjust_wave_scroll();
1422 }
1423 }
1424 FocusedPanel::Agents => self.next_agent(),
1425 FocusedPanel::Output => self.scroll_down(1),
1426 }
1427 }
1428
1429 fn adjust_wave_scroll(&mut self) {
1432 let mut line_idx = 0;
1435 let mut found = false;
1436 let mut task_counter = 0;
1437
1438 for wave in &self.waves {
1439 line_idx += 1; for _ in &wave.tasks {
1441 if task_counter == self.wave_task_index {
1442 found = true;
1443 break;
1444 }
1445 line_idx += 1;
1446 task_counter += 1;
1447 }
1448 if found {
1449 break;
1450 }
1451 }
1452
1453 let visible_height = 4;
1455
1456 if line_idx < self.wave_scroll_offset {
1458 self.wave_scroll_offset = line_idx;
1459 } else if line_idx >= self.wave_scroll_offset + visible_height {
1460 self.wave_scroll_offset = line_idx.saturating_sub(visible_height - 1);
1461 }
1462 }
1463
1464 pub fn toggle_task_selection(&mut self) {
1468 if let Some(task) = self.selected_wave_task() {
1469 let task_id = task.id.clone();
1470 if self.selected_tasks.contains(&task_id) {
1471 self.selected_tasks.remove(&task_id);
1472 } else {
1473 if task.state == WaveTaskState::Ready {
1475 self.selected_tasks.insert(task_id);
1476 }
1477 }
1478 }
1479 }
1480
1481 pub fn select_all_ready(&mut self) {
1483 for wave in &self.waves {
1484 for task in &wave.tasks {
1485 if task.state == WaveTaskState::Ready {
1486 self.selected_tasks.insert(task.id.clone());
1487 }
1488 }
1489 }
1490 }
1491
1492 pub fn clear_selection(&mut self) {
1494 self.selected_tasks.clear();
1495 }
1496
1497 pub fn ready_task_count(&self) -> usize {
1499 self.waves
1500 .iter()
1501 .flat_map(|w| &w.tasks)
1502 .filter(|t| t.state == WaveTaskState::Ready)
1503 .count()
1504 }
1505
1506 pub fn selected_task_count(&self) -> usize {
1508 self.selected_tasks.len()
1509 }
1510
1511 pub fn get_selected_tasks(&self) -> Vec<&WaveTask> {
1513 self.all_wave_tasks()
1514 .into_iter()
1515 .filter(|t| self.selected_tasks.contains(&t.id))
1516 .collect()
1517 }
1518
1519 pub fn spawn_selected_tasks(&mut self) -> Result<usize> {
1522 use crate::commands::spawn::{agent, terminal};
1523
1524 let tasks_to_spawn: Vec<(String, String, String)> = self
1525 .get_selected_tasks()
1526 .iter()
1527 .map(|t| (t.id.clone(), t.title.clone(), t.tag.clone()))
1528 .collect();
1529
1530 if tasks_to_spawn.is_empty() {
1531 return Ok(0);
1532 }
1533
1534 let working_dir = self
1536 .project_root
1537 .clone()
1538 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1539
1540 let session = match &self.session {
1542 Some(s) => s,
1543 None => {
1544 self.error = Some("No session loaded".to_string());
1545 return Ok(0);
1546 }
1547 };
1548
1549 let session_name = session.session_name.clone();
1550 let mut spawned_count = 0;
1551
1552 let storage = Storage::new(self.project_root.clone());
1554
1555 for (task_id, task_title, tag) in &tasks_to_spawn {
1556 let phase = match self.phases.get(tag) {
1558 Some(p) => p,
1559 None => continue,
1560 };
1561
1562 let task = match phase.get_task(task_id) {
1563 Some(t) => t,
1564 None => continue,
1565 };
1566
1567 let prompt = agent::generate_prompt(task, tag);
1569
1570 let spawn_config = terminal::SpawnConfig::new(task_id, &prompt, &working_dir, &session_name);
1572 match terminal::spawn_tmux_agent(&spawn_config) {
1573 Ok(_window_index) => {
1574 spawned_count += 1;
1575
1576 if let Some(ref mut session) = self.session {
1578 session.add_agent(task_id, task_title, tag);
1579 }
1580
1581 if let Ok(mut phase) = storage.load_group(tag) {
1583 if let Some(task) = phase.get_task_mut(task_id) {
1584 task.set_status(TaskStatus::InProgress);
1585 let _ = storage.update_group(tag, &phase);
1586 }
1587 }
1588 }
1589 Err(e) => {
1590 self.error = Some(format!("Failed to spawn {}: {}", task_id, e));
1591 }
1592 }
1593
1594 if spawned_count < tasks_to_spawn.len() {
1596 std::thread::sleep(Duration::from_millis(300));
1597 }
1598 }
1599
1600 if spawned_count > 0 {
1602 if let Some(ref session) = self.session {
1603 let _ = crate::commands::spawn::monitor::save_session(
1604 self.project_root.as_ref(),
1605 session,
1606 );
1607 }
1608
1609 self.selected_tasks.clear();
1611 self.refresh()?;
1612 self.refresh_waves();
1613 }
1614
1615 Ok(spawned_count)
1616 }
1617
1618 pub fn prepare_swarm_start(&self) -> Option<(String, String)> {
1620 let tag = self
1622 .session
1623 .as_ref()
1624 .map(|s| s.tag.clone())
1625 .or_else(|| self.active_tag.clone())?;
1626
1627 let session_base = self.session_name.replace("swarm-", "").replace("scud-", "");
1629 let cmd = format!("scud swarm --tag {} --session {}", tag, session_base);
1630
1631 Some((cmd, tag))
1632 }
1633
1634 pub fn set_selected_task_status(&mut self, new_status: TaskStatus) -> Result<()> {
1636 let Some(ref session) = self.session else {
1637 self.error = Some("No session loaded".to_string());
1638 return Ok(());
1639 };
1640
1641 let agents = session.agents.clone();
1642 if agents.is_empty() || self.selected >= agents.len() {
1643 self.error = Some("No agent selected".to_string());
1644 return Ok(());
1645 }
1646
1647 let agent = &agents[self.selected];
1648 let task_id = &agent.task_id;
1649 let tag = &agent.tag;
1650
1651 let storage = Storage::new(self.project_root.clone());
1653 if let Ok(mut phase) = storage.load_group(tag) {
1654 if let Some(task) = phase.get_task_mut(task_id) {
1655 task.set_status(new_status.clone());
1656 if let Err(e) = storage.update_group(tag, &phase) {
1657 self.error = Some(format!("Failed to save: {}", e));
1658 return Ok(());
1659 }
1660 self.error = Some(format!("✓ {} → {}", task_id, new_status.as_str()));
1662 } else {
1663 self.error = Some(format!("Task {} not found", task_id));
1664 }
1665 } else {
1666 self.error = Some(format!("Failed to load phase {}", tag));
1667 }
1668
1669 self.refresh()?;
1671 self.refresh_waves();
1672
1673 Ok(())
1674 }
1675
1676 fn spawn_task_with_ralph(&mut self, task_id: &str) -> Result<()> {
1679 use crate::commands::spawn::{agent, terminal};
1680
1681 let task_info = self
1683 .waves
1684 .iter()
1685 .flat_map(|w| w.tasks.iter())
1686 .find(|t| t.id == task_id)
1687 .map(|t| (t.id.clone(), t.title.clone(), t.tag.clone()));
1688
1689 let (task_id, task_title, tag) = match task_info {
1690 Some(info) => info,
1691 None => return Ok(()),
1692 };
1693
1694 let working_dir = self
1696 .project_root
1697 .clone()
1698 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1699
1700 let session = match &self.session {
1702 Some(s) => s,
1703 None => {
1704 self.error = Some("No session loaded".to_string());
1705 return Ok(());
1706 }
1707 };
1708
1709 let session_name = session.session_name.clone();
1710
1711 let storage = Storage::new(self.project_root.clone());
1713
1714 let phase = match self.phases.get(&tag) {
1715 Some(p) => p,
1716 None => return Ok(()),
1717 };
1718
1719 let task = match phase.get_task(&task_id) {
1720 Some(t) => t,
1721 None => return Ok(()),
1722 };
1723
1724 let base_prompt = agent::generate_prompt(task, &tag);
1726 let ralph_prompt = format!(
1727 r#"{}
1728
1729═══════════════════════════════════════════════════════════
1730RALPH LOOP MODE - Autonomous Task Completion
1731═══════════════════════════════════════════════════════════
1732
1733CRITICAL: Your task ID is **{task_id}** (NOT any parent task!)
1734
1735You are in a Ralph loop. Keep working until the task is COMPLETE.
1736
1737After EACH attempt:
17381. Run EXACTLY: scud set-status {task_id} done
1739 ⚠️ Use task ID "{task_id}" - do NOT use any other task ID!
17402. Verify the task is truly done (tests pass, code works)
17413. If something failed, fix it and try again
1742
1743The loop will continue until task {task_id} is marked done.
1744Do NOT give up. Keep iterating until success.
1745
1746When you have genuinely completed task {task_id}, output:
1747<promise>TASK {task_id} COMPLETE</promise>
1748
1749DO NOT output this promise unless task {task_id} is TRULY complete!
1750═══════════════════════════════════════════════════════════
1751"#,
1752 base_prompt,
1753 task_id = task_id
1754 );
1755
1756 let spawn_config = terminal::SpawnConfig::new(&task_id, &ralph_prompt, &working_dir, &session_name);
1758 match terminal::spawn_ralph_agent(&spawn_config, &format!("TASK {} COMPLETE", task_id)) {
1759 Ok(()) => {
1760 if let Some(ref mut session) = self.session {
1762 session.add_agent(&task_id, &task_title, &tag);
1763 }
1764
1765 if let Ok(mut phase) = storage.load_group(&tag) {
1767 if let Some(task) = phase.get_task_mut(&task_id) {
1768 task.set_status(TaskStatus::InProgress);
1769 let _ = storage.update_group(&tag, &phase);
1770 }
1771 }
1772
1773 if let Some(ref session) = self.session {
1775 let _ = crate::commands::spawn::monitor::save_session(
1776 self.project_root.as_ref(),
1777 session,
1778 );
1779 }
1780
1781 let _ = self.refresh();
1783 self.refresh_waves();
1784 }
1785 Err(e) => {
1786 self.error = Some(format!(
1787 "Failed to spawn Ralph agent for {}: {}",
1788 task_id, e
1789 ));
1790 }
1791 }
1792
1793 Ok(())
1794 }
1795}