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::{load_session, save_session, AgentState, AgentStatus, SpawnSession};
17use crate::models::phase::Phase;
18use crate::models::task::{Task, TaskStatus};
19use crate::storage::Storage;
20
21#[derive(Debug, Clone, PartialEq)]
23pub enum ViewMode {
24 Split,
26 Fullscreen,
28 Input,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq)]
34pub enum FocusedPanel {
35 Waves,
36 Agents,
37 Output,
38}
39
40#[derive(Debug, Clone, PartialEq)]
42pub enum WaveTaskState {
43 Ready,
45 Running,
47 Done,
49 Blocked,
51 InProgress,
53}
54
55#[derive(Debug, Clone)]
57pub struct WaveTask {
58 pub id: String,
59 pub title: String,
60 pub tag: String,
61 pub state: WaveTaskState,
62 pub complexity: u32,
63 pub dependencies: Vec<String>,
64}
65
66#[derive(Debug, Clone)]
68pub struct Wave {
69 pub number: usize,
70 pub tasks: Vec<WaveTask>,
71}
72
73pub struct App {
75 pub project_root: Option<PathBuf>,
77 pub session_name: String,
79 pub session: Option<SpawnSession>,
81 pub selected: usize,
83 pub view_mode: ViewMode,
85 pub show_help: bool,
87 last_refresh: Instant,
89 refresh_interval: Duration,
91 pub error: Option<String>,
93 pub live_output: Vec<String>,
95 last_output_refresh: Instant,
97 output_refresh_interval: Duration,
99 pub input_buffer: String,
101 pub scroll_offset: usize,
103 pub auto_scroll: bool,
105
106 pub focused_panel: FocusedPanel,
109 pub waves: Vec<Wave>,
111 pub selected_tasks: HashSet<String>,
113 pub wave_task_index: usize,
115 pub wave_scroll_offset: usize,
117 pub agents_scroll_offset: usize,
119 pub active_tag: Option<String>,
121 phases: HashMap<String, Phase>,
123
124 pub ralph_mode: bool,
127 pub ralph_max_parallel: usize,
129 last_ralph_check: Instant,
131}
132
133impl App {
134 pub fn new(project_root: Option<PathBuf>, session_name: &str) -> Result<Self> {
136 let storage = Storage::new(project_root.clone());
138 let active_tag = storage.get_active_group().ok().flatten();
139 let phases = storage.load_tasks().unwrap_or_default();
140
141 let mut app = Self {
142 project_root,
143 session_name: session_name.to_string(),
144 session: None,
145 selected: 0,
146 view_mode: ViewMode::Split,
147 show_help: false,
148 last_refresh: Instant::now(),
149 refresh_interval: Duration::from_secs(2),
150 error: None,
151 live_output: Vec::new(),
152 last_output_refresh: Instant::now(),
153 output_refresh_interval: Duration::from_millis(500),
154 input_buffer: String::new(),
155 scroll_offset: 0,
156 auto_scroll: true,
157 focused_panel: FocusedPanel::Waves,
159 waves: Vec::new(),
160 selected_tasks: HashSet::new(),
161 wave_task_index: 0,
162 wave_scroll_offset: 0,
163 agents_scroll_offset: 0,
164 active_tag,
165 phases,
166 ralph_mode: false,
168 ralph_max_parallel: 5,
169 last_ralph_check: Instant::now(),
170 };
171 app.refresh()?;
172 app.refresh_waves();
173 app.refresh_live_output();
174 Ok(app)
175 }
176
177 pub fn refresh(&mut self) -> Result<()> {
179 match load_session(self.project_root.as_ref(), &self.session_name) {
180 Ok(mut session) => {
181 self.refresh_agent_statuses(&mut session);
183
184 let _ = save_session(self.project_root.as_ref(), &session);
186
187 self.session = Some(session);
188 self.error = None;
189 }
190 Err(e) => {
191 self.error = Some(format!("Failed to load session: {}", e));
192 }
193 }
194 self.last_refresh = Instant::now();
195 Ok(())
196 }
197
198 pub fn refresh_live_output(&mut self) {
200 let agents = self.agents();
201 if agents.is_empty() || self.selected >= agents.len() {
202 self.live_output = vec!["No agent selected".to_string()];
203 return;
204 }
205
206 let agent = &agents[self.selected];
207 let session = match &self.session {
208 Some(s) => s,
209 None => {
210 self.live_output = vec!["No session loaded".to_string()];
211 return;
212 }
213 };
214
215 let tmux_windows = self.get_tmux_windows(&session.session_name);
217 let matching_window = tmux_windows.iter().find(|(_, name)| {
218 name.starts_with(&agent.window_name) || agent.window_name.starts_with(name)
219 });
220
221 let window_target = match matching_window {
222 Some((index, _)) => format!("{}:{}", session.session_name, index),
223 None => {
224 self.live_output = vec![format!("Window '{}' not found", agent.window_name)];
225 return;
226 }
227 };
228
229 let output = Command::new("tmux")
231 .args([
232 "capture-pane",
233 "-t", &window_target,
234 "-p", "-S", "-100", ])
237 .output();
238
239 match output {
240 Ok(out) if out.status.success() => {
241 let content = String::from_utf8_lossy(&out.stdout);
242 self.live_output = content
243 .lines()
244 .map(|s| s.to_string())
245 .collect();
246
247 while self.live_output.last().map(|s| s.trim().is_empty()).unwrap_or(false) {
249 self.live_output.pop();
250 }
251 }
252 Ok(out) => {
253 self.live_output = vec![format!(
254 "Error: {}",
255 String::from_utf8_lossy(&out.stderr)
256 )];
257 }
258 Err(e) => {
259 self.live_output = vec![format!("tmux error: {}", e)];
260 }
261 }
262
263 self.last_output_refresh = Instant::now();
264 }
265
266 fn refresh_agent_statuses(&self, session: &mut SpawnSession) {
268 let tmux_windows = self.get_tmux_windows(&session.session_name);
269 let storage = Storage::new(self.project_root.clone());
270 let all_phases = storage.load_tasks().ok();
271
272 for agent in &mut session.agents {
273 let window_exists = tmux_windows.iter().any(|(_, name)| {
274 name.starts_with(&agent.window_name) || agent.window_name.starts_with(name)
275 });
276
277 let task_status = all_phases.as_ref().and_then(|phases| {
278 phases.get(&agent.tag).and_then(|phase| {
279 phase.get_task(&agent.task_id).map(|task| task.status.clone())
280 })
281 });
282
283 agent.status = match (&task_status, window_exists) {
284 (Some(TaskStatus::Done), _) => AgentStatus::Completed,
285 (Some(TaskStatus::Blocked), _) => AgentStatus::Failed,
286 (Some(TaskStatus::InProgress), true) => AgentStatus::Running,
287 (Some(TaskStatus::InProgress), false) => AgentStatus::Completed,
288 (_, false) => AgentStatus::Completed,
289 (_, true) => AgentStatus::Running,
290 };
291 }
292 }
293
294 fn get_tmux_windows(&self, session_name: &str) -> Vec<(usize, String)> {
296 let output = Command::new("tmux")
297 .args(["list-windows", "-t", session_name, "-F", "#{window_index}:#{window_name}"])
298 .output();
299
300 match output {
301 Ok(out) if out.status.success() => {
302 String::from_utf8_lossy(&out.stdout)
303 .lines()
304 .filter_map(|line| {
305 let parts: Vec<&str> = line.splitn(2, ':').collect();
306 if parts.len() == 2 {
307 parts[0].parse().ok().map(|idx| (idx, parts[1].to_string()))
308 } else {
309 None
310 }
311 })
312 .collect()
313 }
314 _ => Vec::new(),
315 }
316 }
317
318 pub fn tick(&mut self) -> Result<()> {
320 if self.last_refresh.elapsed() >= self.refresh_interval {
322 self.refresh()?;
323 self.refresh_waves();
324 }
325
326 if self.last_output_refresh.elapsed() >= self.output_refresh_interval {
328 self.refresh_live_output();
329 }
330
331 if self.ralph_mode && self.last_ralph_check.elapsed() >= Duration::from_secs(5) {
333 self.ralph_auto_spawn();
334 self.last_ralph_check = Instant::now();
335 }
336
337 Ok(())
338 }
339
340 pub fn toggle_ralph_mode(&mut self) {
342 self.ralph_mode = !self.ralph_mode;
343 if self.ralph_mode {
344 self.ralph_auto_spawn();
346 }
347 }
348
349 fn ralph_auto_spawn(&mut self) {
351 let running_count = self.agents().iter()
353 .filter(|a| a.status == AgentStatus::Running || a.status == AgentStatus::Starting)
354 .count();
355
356 if running_count >= self.ralph_max_parallel {
357 return; }
359
360 let slots_available = self.ralph_max_parallel - running_count;
362 let mut tasks_to_spawn: Vec<String> = Vec::new();
363
364 for wave in &self.waves {
365 for task in &wave.tasks {
366 if task.state == WaveTaskState::Ready && !self.selected_tasks.contains(&task.id) {
367 let already_spawned = self.agents().iter()
369 .any(|a| a.task_id == task.id);
370 if !already_spawned {
371 tasks_to_spawn.push(task.id.clone());
372 if tasks_to_spawn.len() >= slots_available {
373 break;
374 }
375 }
376 }
377 }
378 if tasks_to_spawn.len() >= slots_available {
379 break;
380 }
381 }
382
383 for task_id in tasks_to_spawn {
385 let _ = self.spawn_task_with_ralph(&task_id);
386 }
387 }
388
389 pub fn agents(&self) -> &[AgentState] {
391 self.session
392 .as_ref()
393 .map(|s| s.agents.as_slice())
394 .unwrap_or(&[])
395 }
396
397 pub fn next_agent(&mut self) {
399 let len = self.agents().len();
400 if len > 0 {
401 self.selected = (self.selected + 1) % len;
402 self.adjust_agents_scroll();
403 self.reset_scroll();
404 self.refresh_live_output();
405 }
406 }
407
408 pub fn previous_agent(&mut self) {
410 let len = self.agents().len();
411 if len > 0 {
412 self.selected = if self.selected > 0 {
413 self.selected - 1
414 } else {
415 len - 1
416 };
417 self.adjust_agents_scroll();
418 self.reset_scroll();
419 self.refresh_live_output();
420 }
421 }
422
423 pub fn adjust_agents_scroll(&mut self) {
426 const VISIBLE_LINES: usize = 8;
427
428 if self.selected < self.agents_scroll_offset {
430 self.agents_scroll_offset = self.selected;
431 }
432 else if self.selected >= self.agents_scroll_offset + VISIBLE_LINES {
434 self.agents_scroll_offset = self.selected.saturating_sub(VISIBLE_LINES - 1);
435 }
436 }
437
438 pub fn toggle_fullscreen(&mut self) {
440 self.view_mode = match self.view_mode {
441 ViewMode::Split => ViewMode::Fullscreen,
442 ViewMode::Fullscreen => ViewMode::Split,
443 ViewMode::Input => ViewMode::Fullscreen,
444 };
445 }
446
447 pub fn exit_fullscreen(&mut self) {
449 self.view_mode = ViewMode::Split;
450 self.input_buffer.clear();
451 }
452
453 pub fn enter_input_mode(&mut self) {
455 self.view_mode = ViewMode::Input;
456 self.input_buffer.clear();
457 }
458
459 pub fn input_char(&mut self, c: char) {
461 self.input_buffer.push(c);
462 }
463
464 pub fn input_backspace(&mut self) {
466 self.input_buffer.pop();
467 }
468
469 pub fn send_input(&mut self) -> Result<()> {
471 if self.input_buffer.is_empty() {
472 return Ok(());
473 }
474
475 let session = match &self.session {
476 Some(s) => s,
477 None => {
478 self.error = Some("No session loaded".to_string());
479 return Ok(());
480 }
481 };
482
483 let agents = self.agents();
484 if agents.is_empty() || self.selected >= agents.len() {
485 self.error = Some("No agent selected".to_string());
486 return Ok(());
487 }
488
489 let agent = &agents[self.selected];
490
491 let tmux_windows = self.get_tmux_windows(&session.session_name);
493 let matching_window = tmux_windows.iter().find(|(_, name)| {
494 name.starts_with(&agent.window_name) || agent.window_name.starts_with(name)
495 });
496
497 let window_target = match matching_window {
498 Some((index, _)) => format!("{}:{}", session.session_name, index),
499 None => {
500 self.error = Some(format!("Window not found for {}", agent.task_id));
501 return Ok(());
502 }
503 };
504
505 let result = Command::new("tmux")
507 .args(["send-keys", "-t", &window_target, &self.input_buffer, "Enter"])
508 .output();
509
510 match result {
511 Ok(out) if out.status.success() => {
512 self.error = None;
513 self.input_buffer.clear();
514 self.view_mode = ViewMode::Fullscreen; self.refresh_live_output();
516 }
517 Ok(out) => {
518 self.error = Some(format!(
519 "Send failed: {}",
520 String::from_utf8_lossy(&out.stderr)
521 ));
522 }
523 Err(e) => {
524 self.error = Some(format!("tmux error: {}", e));
525 }
526 }
527
528 Ok(())
529 }
530
531 pub fn restart_agent(&mut self) -> Result<()> {
533 let session = match &self.session {
534 Some(s) => s,
535 None => return Ok(()),
536 };
537
538 let agents = self.agents();
539 if agents.is_empty() || self.selected >= agents.len() {
540 return Ok(());
541 }
542
543 let agent = &agents[self.selected];
544
545 let tmux_windows = self.get_tmux_windows(&session.session_name);
547 let matching_window = tmux_windows.iter().find(|(_, name)| {
548 name.starts_with(&agent.window_name) || agent.window_name.starts_with(name)
549 });
550
551 if let Some((index, _)) = matching_window {
552 let target = format!("{}:{}", session.session_name, index);
553
554 let _ = Command::new("tmux")
556 .args(["send-keys", "-t", &target, "C-c"])
557 .output();
558
559 std::thread::sleep(Duration::from_millis(200));
561
562 let _ = Command::new("tmux")
564 .args(["send-keys", "-t", &target, "echo 'Agent restarted by user'", "Enter"])
565 .output();
566
567 self.error = None;
568 self.refresh_live_output();
569 }
570
571 Ok(())
572 }
573
574 pub fn toggle_help(&mut self) {
576 self.show_help = !self.show_help;
577 }
578
579 pub fn scroll_up(&mut self, lines: usize) {
581 let max_scroll = self.live_output.len().saturating_sub(1);
582 self.scroll_offset = (self.scroll_offset + lines).min(max_scroll);
583 self.auto_scroll = false;
584 }
585
586 pub fn scroll_down(&mut self, lines: usize) {
588 self.scroll_offset = self.scroll_offset.saturating_sub(lines);
589 if self.scroll_offset == 0 {
590 self.auto_scroll = true;
591 }
592 }
593
594 pub fn scroll_to_bottom(&mut self) {
596 self.scroll_offset = 0;
597 self.auto_scroll = true;
598 }
599
600 fn reset_scroll(&mut self) {
602 self.scroll_offset = 0;
603 self.auto_scroll = true;
604 }
605
606 pub fn status_counts(&self) -> (usize, usize, usize, usize) {
608 let agents = self.agents();
609 let starting = agents.iter().filter(|a| a.status == AgentStatus::Starting).count();
610 let running = agents.iter().filter(|a| a.status == AgentStatus::Running).count();
611 let completed = agents.iter().filter(|a| a.status == AgentStatus::Completed).count();
612 let failed = agents.iter().filter(|a| a.status == AgentStatus::Failed).count();
613 (starting, running, completed, failed)
614 }
615
616 pub fn selected_agent(&self) -> Option<&AgentState> {
618 let agents = self.agents();
619 if agents.is_empty() || self.selected >= agents.len() {
620 None
621 } else {
622 Some(&agents[self.selected])
623 }
624 }
625
626 pub fn refresh_waves(&mut self) {
630 let storage = Storage::new(self.project_root.clone());
632 self.phases = storage.load_tasks().unwrap_or_default();
633
634 let running_task_ids: HashSet<String> = self
636 .agents()
637 .iter()
638 .filter(|a| a.status == AgentStatus::Running || a.status == AgentStatus::Starting)
639 .map(|a| a.task_id.clone())
640 .collect();
641
642 let tag = self.active_tag.clone().or_else(|| {
644 self.session.as_ref().map(|s| s.tag.clone())
646 });
647
648 let Some(tag) = tag else {
649 self.waves = Vec::new();
650 return;
651 };
652
653 let Some(phase) = self.phases.get(&tag) else {
654 self.waves = Vec::new();
655 return;
656 };
657
658 self.waves = self.compute_waves(phase, &running_task_ids);
660 }
661
662 fn compute_waves(&self, phase: &Phase, running_task_ids: &HashSet<String>) -> Vec<Wave> {
664 let mut actionable: Vec<&Task> = Vec::new();
666 for task in &phase.tasks {
667 if task.status == TaskStatus::Done
668 || task.status == TaskStatus::Expanded
669 || task.status == TaskStatus::Cancelled
670 {
671 continue;
672 }
673
674 if !task.subtasks.is_empty() {
676 continue;
677 }
678
679 if let Some(ref parent_id) = task.parent_id {
681 let parent_expanded = phase
682 .get_task(parent_id)
683 .map(|p| p.is_expanded())
684 .unwrap_or(false);
685 if !parent_expanded {
686 continue;
687 }
688 }
689
690 actionable.push(task);
691 }
692
693 if actionable.is_empty() {
694 return Vec::new();
695 }
696
697 let task_ids: HashSet<String> = actionable.iter().map(|t| t.id.clone()).collect();
699 let mut in_degree: HashMap<String, usize> = HashMap::new();
700 let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
701
702 for task in &actionable {
703 in_degree.entry(task.id.clone()).or_insert(0);
704
705 for dep in &task.dependencies {
706 if task_ids.contains(dep) {
707 *in_degree.entry(task.id.clone()).or_insert(0) += 1;
709 dependents
710 .entry(dep.clone())
711 .or_default()
712 .push(task.id.clone());
713 } else {
714 if !self.is_dependency_satisfied(dep, phase) {
717 *in_degree.entry(task.id.clone()).or_insert(0) += 1000;
719 }
720 }
721 }
722 }
723
724 let mut waves: Vec<Wave> = Vec::new();
726 let mut remaining = in_degree.clone();
727 let mut wave_number = 1;
728
729 while !remaining.is_empty() {
730 let mut ready: Vec<String> = remaining
731 .iter()
732 .filter(|(_, °)| deg == 0)
733 .map(|(id, _)| id.clone())
734 .collect();
735
736 if ready.is_empty() {
737 break; }
739
740 ready.sort();
742
743 let mut wave_tasks: Vec<WaveTask> = ready
745 .iter()
746 .filter_map(|task_id| {
747 actionable.iter().find(|t| &t.id == task_id).map(|task| {
748 let state = if task.status == TaskStatus::Done {
749 WaveTaskState::Done
750 } else if running_task_ids.contains(&task.id) {
751 WaveTaskState::Running
752 } else if task.status == TaskStatus::InProgress {
753 WaveTaskState::InProgress
754 } else if task.status == TaskStatus::Blocked {
755 WaveTaskState::Blocked
756 } else if self.is_task_ready(task, phase) {
757 WaveTaskState::Ready
758 } else {
759 WaveTaskState::Blocked
760 };
761
762 WaveTask {
763 id: task.id.clone(),
764 title: task.title.clone(),
765 tag: self.active_tag.clone().unwrap_or_default(),
766 state,
767 complexity: task.complexity,
768 dependencies: task.dependencies.clone(),
769 }
770 })
771 })
772 .collect();
773
774 for task_id in &ready {
776 remaining.remove(task_id);
777 if let Some(deps) = dependents.get(task_id) {
778 for dep_id in deps {
779 if let Some(deg) = remaining.get_mut(dep_id) {
780 *deg = deg.saturating_sub(1);
781 }
782 }
783 }
784 }
785
786 if !wave_tasks.is_empty() {
787 wave_tasks.sort_by(|a, b| a.id.cmp(&b.id));
789 waves.push(Wave {
790 number: wave_number,
791 tasks: wave_tasks,
792 });
793 }
794 wave_number += 1;
795 }
796
797 waves
798 }
799
800 fn is_task_ready(&self, task: &Task, phase: &Phase) -> bool {
802 if task.status != TaskStatus::Pending {
803 return false;
804 }
805
806 for dep_id in &task.dependencies {
808 if !self.is_dependency_satisfied(dep_id, phase) {
809 return false;
810 }
811 }
812
813 true
814 }
815
816 fn is_dependency_satisfied(&self, dep_id: &str, phase: &Phase) -> bool {
818 let Some(dep) = phase.get_task(dep_id) else {
819 return true; };
821
822 match dep.status {
823 TaskStatus::Done => true,
824 TaskStatus::Expanded => {
825 if dep.subtasks.is_empty() {
827 false } else {
829 dep.subtasks.iter().all(|subtask_id| {
830 phase
831 .get_task(subtask_id)
832 .map(|st| st.status == TaskStatus::Done)
833 .unwrap_or(false)
834 })
835 }
836 }
837 _ => false, }
839 }
840
841 pub fn all_wave_tasks(&self) -> Vec<&WaveTask> {
843 self.waves.iter().flat_map(|w| w.tasks.iter()).collect()
844 }
845
846 pub fn selected_wave_task(&self) -> Option<&WaveTask> {
848 let all_tasks = self.all_wave_tasks();
849 all_tasks.get(self.wave_task_index).copied()
850 }
851
852 pub fn next_panel(&mut self) {
856 self.focused_panel = match self.focused_panel {
857 FocusedPanel::Waves => FocusedPanel::Agents,
858 FocusedPanel::Agents => FocusedPanel::Output,
859 FocusedPanel::Output => FocusedPanel::Waves,
860 };
861 }
862
863 pub fn previous_panel(&mut self) {
865 self.focused_panel = match self.focused_panel {
866 FocusedPanel::Waves => FocusedPanel::Output,
867 FocusedPanel::Agents => FocusedPanel::Waves,
868 FocusedPanel::Output => FocusedPanel::Agents,
869 };
870 }
871
872 pub fn move_up(&mut self) {
874 match self.focused_panel {
875 FocusedPanel::Waves => {
876 if self.wave_task_index > 0 {
877 self.wave_task_index -= 1;
878 self.adjust_wave_scroll();
879 }
880 }
881 FocusedPanel::Agents => self.previous_agent(),
882 FocusedPanel::Output => self.scroll_up(1),
883 }
884 }
885
886 pub fn move_down(&mut self) {
888 match self.focused_panel {
889 FocusedPanel::Waves => {
890 let max = self.all_wave_tasks().len().saturating_sub(1);
891 if self.wave_task_index < max {
892 self.wave_task_index += 1;
893 self.adjust_wave_scroll();
894 }
895 }
896 FocusedPanel::Agents => self.next_agent(),
897 FocusedPanel::Output => self.scroll_down(1),
898 }
899 }
900
901 fn adjust_wave_scroll(&mut self) {
904 let mut line_idx = 0;
907 let mut found = false;
908 let mut task_counter = 0;
909
910 for wave in &self.waves {
911 line_idx += 1; for _ in &wave.tasks {
913 if task_counter == self.wave_task_index {
914 found = true;
915 break;
916 }
917 line_idx += 1;
918 task_counter += 1;
919 }
920 if found {
921 break;
922 }
923 }
924
925 let visible_height = 4;
927
928 if line_idx < self.wave_scroll_offset {
930 self.wave_scroll_offset = line_idx;
931 } else if line_idx >= self.wave_scroll_offset + visible_height {
932 self.wave_scroll_offset = line_idx.saturating_sub(visible_height - 1);
933 }
934 }
935
936 pub fn toggle_task_selection(&mut self) {
940 if let Some(task) = self.selected_wave_task() {
941 let task_id = task.id.clone();
942 if self.selected_tasks.contains(&task_id) {
943 self.selected_tasks.remove(&task_id);
944 } else {
945 if task.state == WaveTaskState::Ready {
947 self.selected_tasks.insert(task_id);
948 }
949 }
950 }
951 }
952
953 pub fn select_all_ready(&mut self) {
955 for wave in &self.waves {
956 for task in &wave.tasks {
957 if task.state == WaveTaskState::Ready {
958 self.selected_tasks.insert(task.id.clone());
959 }
960 }
961 }
962 }
963
964 pub fn clear_selection(&mut self) {
966 self.selected_tasks.clear();
967 }
968
969 pub fn ready_task_count(&self) -> usize {
971 self.waves
972 .iter()
973 .flat_map(|w| &w.tasks)
974 .filter(|t| t.state == WaveTaskState::Ready)
975 .count()
976 }
977
978 pub fn selected_task_count(&self) -> usize {
980 self.selected_tasks.len()
981 }
982
983 pub fn get_selected_tasks(&self) -> Vec<&WaveTask> {
985 self.all_wave_tasks()
986 .into_iter()
987 .filter(|t| self.selected_tasks.contains(&t.id))
988 .collect()
989 }
990
991 pub fn spawn_selected_tasks(&mut self) -> Result<usize> {
994 use crate::commands::spawn::{agent, terminal};
995
996 let tasks_to_spawn: Vec<(String, String, String)> = self
997 .get_selected_tasks()
998 .iter()
999 .map(|t| (t.id.clone(), t.title.clone(), t.tag.clone()))
1000 .collect();
1001
1002 if tasks_to_spawn.is_empty() {
1003 return Ok(0);
1004 }
1005
1006 let working_dir = self
1008 .project_root
1009 .clone()
1010 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1011
1012 let session = match &self.session {
1014 Some(s) => s,
1015 None => {
1016 self.error = Some("No session loaded".to_string());
1017 return Ok(0);
1018 }
1019 };
1020
1021 let session_name = session.session_name.clone();
1022 let mut spawned_count = 0;
1023
1024 let storage = Storage::new(self.project_root.clone());
1026
1027 for (task_id, task_title, tag) in &tasks_to_spawn {
1028 let phase = match self.phases.get(tag) {
1030 Some(p) => p,
1031 None => continue,
1032 };
1033
1034 let task = match phase.get_task(task_id) {
1035 Some(t) => t,
1036 None => continue,
1037 };
1038
1039 let prompt = agent::generate_prompt(task, tag);
1041
1042 match terminal::spawn_terminal(
1044 &terminal::Terminal::Tmux,
1045 task_id,
1046 &prompt,
1047 &working_dir,
1048 &session_name,
1049 ) {
1050 Ok(()) => {
1051 spawned_count += 1;
1052
1053 if let Some(ref mut session) = self.session {
1055 session.add_agent(task_id, task_title, tag);
1056 }
1057
1058 if let Ok(mut phase) = storage.load_group(tag) {
1060 if let Some(task) = phase.get_task_mut(task_id) {
1061 task.set_status(TaskStatus::InProgress);
1062 let _ = storage.update_group(tag, &phase);
1063 }
1064 }
1065 }
1066 Err(e) => {
1067 self.error = Some(format!("Failed to spawn {}: {}", task_id, e));
1068 }
1069 }
1070
1071 if spawned_count < tasks_to_spawn.len() {
1073 std::thread::sleep(Duration::from_millis(300));
1074 }
1075 }
1076
1077 if spawned_count > 0 {
1079 if let Some(ref session) = self.session {
1080 let _ = crate::commands::spawn::monitor::save_session(
1081 self.project_root.as_ref(),
1082 session,
1083 );
1084 }
1085
1086 self.selected_tasks.clear();
1088 self.refresh()?;
1089 self.refresh_waves();
1090 }
1091
1092 Ok(spawned_count)
1093 }
1094
1095 fn spawn_task_with_ralph(&mut self, task_id: &str) -> Result<()> {
1098 use crate::commands::spawn::{agent, terminal};
1099
1100 let task_info = self.waves.iter()
1102 .flat_map(|w| w.tasks.iter())
1103 .find(|t| t.id == task_id)
1104 .map(|t| (t.id.clone(), t.title.clone(), t.tag.clone()));
1105
1106 let (task_id, task_title, tag) = match task_info {
1107 Some(info) => info,
1108 None => return Ok(()),
1109 };
1110
1111 let working_dir = self
1113 .project_root
1114 .clone()
1115 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1116
1117 let session = match &self.session {
1119 Some(s) => s,
1120 None => {
1121 self.error = Some("No session loaded".to_string());
1122 return Ok(());
1123 }
1124 };
1125
1126 let session_name = session.session_name.clone();
1127
1128 let storage = Storage::new(self.project_root.clone());
1130
1131 let phase = match self.phases.get(&tag) {
1132 Some(p) => p,
1133 None => return Ok(()),
1134 };
1135
1136 let task = match phase.get_task(&task_id) {
1137 Some(t) => t,
1138 None => return Ok(()),
1139 };
1140
1141 let base_prompt = agent::generate_prompt(task, &tag);
1143 let ralph_prompt = format!(
1144 r#"{}
1145
1146═══════════════════════════════════════════════════════════
1147RALPH LOOP MODE - Autonomous Task Completion
1148═══════════════════════════════════════════════════════════
1149
1150CRITICAL: Your task ID is **{task_id}** (NOT any parent task!)
1151
1152You are in a Ralph loop. Keep working until the task is COMPLETE.
1153
1154After EACH attempt:
11551. Run EXACTLY: scud set-status {task_id} done
1156 ⚠️ Use task ID "{task_id}" - do NOT use any other task ID!
11572. Verify the task is truly done (tests pass, code works)
11583. If something failed, fix it and try again
1159
1160The loop will continue until task {task_id} is marked done.
1161Do NOT give up. Keep iterating until success.
1162
1163When you have genuinely completed task {task_id}, output:
1164<promise>TASK {task_id} COMPLETE</promise>
1165
1166DO NOT output this promise unless task {task_id} is TRULY complete!
1167═══════════════════════════════════════════════════════════
1168"#,
1169 base_prompt,
1170 task_id = task_id
1171 );
1172
1173 match terminal::spawn_terminal_ralph(
1175 &terminal::Terminal::Tmux,
1176 &task_id,
1177 &ralph_prompt,
1178 &working_dir,
1179 &session_name,
1180 &format!("TASK {} COMPLETE", task_id),
1181 ) {
1182 Ok(()) => {
1183 if let Some(ref mut session) = self.session {
1185 session.add_agent(&task_id, &task_title, &tag);
1186 }
1187
1188 if let Ok(mut phase) = storage.load_group(&tag) {
1190 if let Some(task) = phase.get_task_mut(&task_id) {
1191 task.set_status(TaskStatus::InProgress);
1192 let _ = storage.update_group(&tag, &phase);
1193 }
1194 }
1195
1196 if let Some(ref session) = self.session {
1198 let _ = crate::commands::spawn::monitor::save_session(
1199 self.project_root.as_ref(),
1200 session,
1201 );
1202 }
1203
1204 let _ = self.refresh();
1206 self.refresh_waves();
1207 }
1208 Err(e) => {
1209 self.error = Some(format!("Failed to spawn Ralph agent for {}: {}", task_id, e));
1210 }
1211 }
1212
1213 Ok(())
1214 }
1215}