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