Skip to main content

scud/commands/spawn/tui/
app.rs

1//! Application state for TUI monitor
2//!
3//! Three-panel design:
4//! - Top: Waves/Tasks panel showing tasks by execution wave
5//! - Middle: Agents panel showing running agents
6//! - Bottom: Live terminal output from selected agent
7//!
8//! Tab switches focus between panels. Space toggles task selection for spawning.
9
10use 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/// View mode for the TUI
26#[derive(Debug, Clone, PartialEq)]
27pub enum ViewMode {
28    /// Three-panel view: waves + agents + output
29    Split,
30    /// Fullscreen: just the selected agent's terminal
31    Fullscreen,
32    /// Input mode: typing a command to send to agent
33    Input,
34}
35
36/// Which panel is focused
37#[derive(Debug, Clone, Copy, PartialEq)]
38pub enum FocusedPanel {
39    Waves,
40    Agents,
41    Output,
42}
43
44/// Task state in the waves view
45#[derive(Debug, Clone, PartialEq)]
46pub enum WaveTaskState {
47    /// Ready to spawn (dependencies met, pending)
48    Ready,
49    /// Currently running (agent spawned)
50    Running,
51    /// Completed
52    Done,
53    /// Blocked by dependencies
54    Blocked,
55    /// In progress but no agent visible
56    InProgress,
57}
58
59/// Information about a task in a wave
60#[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/// A wave of tasks that can run in parallel
71#[derive(Debug, Clone)]
72pub struct Wave {
73    pub number: usize,
74    pub tasks: Vec<WaveTask>,
75}
76
77/// Progress tracking for swarm execution
78///
79/// Provides a consolidated view of swarm progress including:
80/// - Current/total waves
81/// - Task completion counts
82/// - Validation status
83#[derive(Debug, Clone, Default)]
84pub struct SwarmProgress {
85    /// Current wave number being executed (1-indexed, 0 if not started)
86    pub current_wave: usize,
87    /// Total number of waves in the execution plan
88    pub total_waves: usize,
89    /// Number of tasks completed successfully (status = Done)
90    pub tasks_completed: usize,
91    /// Total number of tasks in the swarm
92    pub tasks_total: usize,
93    /// Number of tasks currently in progress
94    pub tasks_in_progress: usize,
95    /// Number of tasks that failed or are blocked
96    pub tasks_failed: usize,
97    /// Number of waves that have passed validation
98    pub waves_validated: usize,
99    /// Number of waves that failed validation
100    pub waves_failed_validation: usize,
101    /// Total repair attempts across all waves
102    pub total_repairs: usize,
103}
104
105/// Application state
106pub struct App {
107    /// Project root directory
108    pub project_root: Option<PathBuf>,
109    /// Session name being monitored
110    pub session_name: String,
111    /// Current spawn session data
112    pub session: Option<SpawnSession>,
113    /// Selected agent index
114    pub selected: usize,
115    /// Current view mode
116    pub view_mode: ViewMode,
117    /// Show help overlay
118    pub show_help: bool,
119    /// Last refresh time
120    last_refresh: Instant,
121    /// Refresh interval
122    refresh_interval: Duration,
123    /// Error message to display
124    pub error: Option<String>,
125    /// Live terminal output for selected agent (cached)
126    pub live_output: Vec<String>,
127    /// Last output refresh
128    last_output_refresh: Instant,
129    /// Output refresh interval (faster than status refresh)
130    output_refresh_interval: Duration,
131    /// Input buffer for sending commands to agent
132    pub input_buffer: String,
133    /// Scroll offset for terminal output (0 = bottom, positive = scrolled up)
134    pub scroll_offset: usize,
135    /// Auto-scroll to bottom on new output
136    pub auto_scroll: bool,
137
138    // === New three-panel state ===
139    /// Which panel is currently focused
140    pub focused_panel: FocusedPanel,
141    /// Execution waves with tasks
142    pub waves: Vec<Wave>,
143    /// Selected task IDs for batch spawning
144    pub selected_tasks: HashSet<String>,
145    /// Selected task index within waves panel
146    pub wave_task_index: usize,
147    /// Scroll offset for waves panel (first visible line)
148    pub wave_scroll_offset: usize,
149    /// Scroll offset for agents panel (first visible line)
150    pub agents_scroll_offset: usize,
151    /// Active tag for loading waves
152    pub active_tag: Option<String>,
153    /// All phases data (cached)
154    phases: HashMap<String, Phase>,
155
156    // === Ralph Mode (autonomous wave execution) ===
157    /// Whether Ralph mode is enabled (auto-spawn ready tasks)
158    pub ralph_mode: bool,
159    /// Maximum parallel agents in Ralph mode
160    pub ralph_max_parallel: usize,
161    /// Last time we checked for new tasks to spawn in Ralph mode
162    last_ralph_check: Instant,
163
164    // === Swarm Mode ===
165    /// Whether we're monitoring a swarm session (vs spawn session)
166    pub swarm_mode: bool,
167    /// Swarm session data (when swarm_mode is true)
168    pub swarm_session_data: Option<swarm_session::SwarmSession>,
169    /// Swarm progress tracking (computed from swarm_session_data and phases)
170    pub swarm_progress: Option<SwarmProgress>,
171
172    // === Headless Mode ===
173    /// Stream store for headless mode (None = tmux mode)
174    pub stream_store: Option<StreamStore>,
175}
176
177impl App {
178    /// Create new app state
179    ///
180    /// # Arguments
181    /// * `project_root` - Optional project root directory
182    /// * `session_name` - Name of the session to monitor
183    /// * `swarm_mode` - Whether to monitor a swarm session
184    /// * `stream_store` - Optional StreamStore for headless mode (None = tmux mode)
185    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        // Load storage to get active tag and phases
192        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            // New fields
213            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
222            ralph_mode: false,
223            ralph_max_parallel: 5,
224            last_ralph_check: Instant::now(),
225            // Swarm mode
226            swarm_mode,
227            swarm_session_data: None,
228            swarm_progress: None,
229            // Headless mode
230            stream_store,
231        };
232        app.refresh()?;
233        app.refresh_waves();
234        app.refresh_live_output();
235        Ok(app)
236    }
237
238    // === Socket Feed Methods (feature-gated) ===
239
240    /// Set the socket feed handle
241    #[cfg(feature = "socket-feed")]
242    pub fn set_feed_handle(&mut self, handle: Option<FeedHandleSync>) {
243        self.feed_handle = handle;
244    }
245
246    /// Set the socket feed handle (stub when feature disabled)
247    #[cfg(not(feature = "socket-feed"))]
248    pub fn set_feed_handle(&mut self, _handle: Option<()>) {
249        // No-op when socket-feed feature is disabled
250    }
251
252    /// Check if feed is active
253    #[cfg(feature = "socket-feed")]
254    pub fn has_feed(&self) -> bool {
255        self.feed_handle.is_some()
256    }
257
258    /// Check if feed is active (always false when feature disabled)
259    #[cfg(not(feature = "socket-feed"))]
260    pub fn has_feed(&self) -> bool {
261        false
262    }
263
264    /// Publish full session snapshot to feed
265    #[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    /// Publish full session snapshot to feed (stub when feature disabled)
274    #[cfg(not(feature = "socket-feed"))]
275    pub fn publish_session_snapshot(&self) {
276        // No-op when socket-feed feature is disabled
277    }
278
279    /// Publish live output to feed
280    #[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    /// Publish live output to feed (stub when feature disabled)
291    #[cfg(not(feature = "socket-feed"))]
292    pub fn publish_output(&self) {
293        // No-op when socket-feed feature is disabled
294    }
295
296    /// Publish wave/task progress to feed
297    #[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    /// Publish wave/task progress to feed (stub when feature disabled)
334    #[cfg(not(feature = "socket-feed"))]
335    pub fn publish_wave_update(&self) {
336        // No-op when socket-feed feature is disabled
337    }
338
339    /// Publish stats to feed
340    #[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    /// Publish stats to feed (stub when feature disabled)
350    #[cfg(not(feature = "socket-feed"))]
351    pub fn publish_stats(&self) {
352        // No-op when socket-feed feature is disabled
353    }
354
355    /// Check for and publish agent status changes
356    #[cfg(feature = "socket-feed")]
357    pub fn publish_agent_changes(&mut self) {
358        // Collect updates first to avoid borrow issues
359        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        // Publish updates
380        if let Some(handle) = &self.feed_handle {
381            for update in updates {
382                handle.publish_agent_update(update);
383            }
384        }
385
386        // Collect new statuses to avoid borrow issues
387        let new_statuses: Vec<_> = self
388            .agents()
389            .iter()
390            .map(|a| (a.task_id.clone(), a.status.clone()))
391            .collect();
392
393        // Update previous statuses
394        self.previous_agent_statuses.clear();
395        for (id, status) in new_statuses {
396            self.previous_agent_statuses.insert(id, status);
397        }
398    }
399
400    /// Check for and publish agent status changes (stub when feature disabled)
401    #[cfg(not(feature = "socket-feed"))]
402    pub fn publish_agent_changes(&mut self) {
403        // No-op when socket-feed feature is disabled
404    }
405
406    /// Shutdown the feed
407    pub fn shutdown_feed(&self) {
408        // Feed will shutdown when handle is dropped
409        // Nothing explicit needed here
410    }
411
412    /// Count wave task states (only used by socket-feed)
413    #[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    /// Refresh session data from disk and update agent statuses
435    pub fn refresh(&mut self) -> Result<()> {
436        if self.swarm_mode {
437            // Load swarm session
438            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                    // Update swarm progress after loading session
443                    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            // Load spawn session
452            match load_session(self.project_root.as_ref(), &self.session_name) {
453                Ok(mut session) => {
454                    // Update agent statuses from tmux and SCUD task status
455                    self.refresh_agent_statuses(&mut session);
456
457                    // Save updated session back to disk
458                    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    /// Refresh live output from the selected agent
473    ///
474    /// In headless mode, reads from StreamStore. Otherwise, captures tmux pane content.
475    pub fn refresh_live_output(&mut self) {
476        // If we have a stream store (headless mode), read from it
477        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        // Fall back to tmux capture-pane (default mode)
496        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        // Get tmux windows to find the correct window index
512        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        // Capture pane content with scrollback
526        let output = Command::new("tmux")
527            .args([
528                "capture-pane",
529                "-t",
530                &window_target,
531                "-p", // print to stdout
532                "-S",
533                "-100", // start from 100 lines back
534            ])
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                // Remove trailing empty lines
543                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    /// Refresh agent statuses by checking tmux windows and SCUD task status
564    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                // In-progress task without a backing window is likely orphaned/stuck.
587                (Some(TaskStatus::InProgress), false) => AgentStatus::Failed,
588                (_, false) => AgentStatus::Completed,
589                (_, true) => AgentStatus::Running,
590            };
591        }
592    }
593
594    /// Get list of tmux windows for a session: [(index, name), ...]
595    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    /// Periodic tick - refresh data as needed
648    pub fn tick(&mut self) -> Result<()> {
649        // Refresh session/status data periodically
650        if self.last_refresh.elapsed() >= self.refresh_interval {
651            self.refresh()?;
652            self.refresh_waves();
653
654            // Publish changes to feed
655            if self.has_feed() {
656                self.publish_agent_changes();
657                self.publish_wave_update();
658                self.publish_stats();
659            }
660        }
661
662        // Refresh live output more frequently
663        if self.last_output_refresh.elapsed() >= self.output_refresh_interval {
664            self.refresh_live_output();
665
666            // Publish output to feed
667            if self.has_feed() {
668                self.publish_output();
669            }
670        }
671
672        // Periodic full session snapshot (less frequent) - only when socket-feed enabled
673        #[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        // Ralph mode: auto-spawn ready tasks
680        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    /// Toggle Ralph mode (autonomous wave execution)
689    pub fn toggle_ralph_mode(&mut self) {
690        self.ralph_mode = !self.ralph_mode;
691        if self.ralph_mode {
692            // Immediately check for tasks to spawn
693            self.ralph_auto_spawn();
694        }
695    }
696
697    /// Auto-spawn ready tasks in Ralph mode
698    fn ralph_auto_spawn(&mut self) {
699        // Count running agents
700        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; // Already at max parallel
708        }
709
710        // Find ready tasks to spawn
711        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                    // Check if already have an agent for this task
718                    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        // Spawn the tasks with Ralph loop enabled
733        for task_id in tasks_to_spawn {
734            let _ = self.spawn_task_with_ralph(&task_id);
735        }
736    }
737
738    /// Get agents list
739    pub fn agents(&self) -> &[AgentState] {
740        self.session
741            .as_ref()
742            .map(|s| s.agents.as_slice())
743            .unwrap_or(&[])
744    }
745
746    /// Select next agent
747    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    /// Select previous agent
758    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    /// Adjust agents scroll offset to keep selected agent visible
773    /// Assumes roughly 8 visible lines in the agents panel
774    pub fn adjust_agents_scroll(&mut self) {
775        const VISIBLE_LINES: usize = 8;
776
777        // Scroll up if selected is above visible area
778        if self.selected < self.agents_scroll_offset {
779            self.agents_scroll_offset = self.selected;
780        }
781        // Scroll down if selected is below visible area
782        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    /// Toggle fullscreen mode
788    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    /// Exit current mode (go back to split)
797    pub fn exit_fullscreen(&mut self) {
798        self.view_mode = ViewMode::Split;
799        self.input_buffer.clear();
800    }
801
802    /// Enter input mode
803    pub fn enter_input_mode(&mut self) {
804        self.view_mode = ViewMode::Input;
805        self.input_buffer.clear();
806    }
807
808    /// Add character to input buffer
809    pub fn input_char(&mut self, c: char) {
810        self.input_buffer.push(c);
811    }
812
813    /// Delete last character from input buffer
814    pub fn input_backspace(&mut self) {
815        self.input_buffer.pop();
816    }
817
818    /// Send the input buffer to the selected agent's tmux pane
819    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        // Find window index
841        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        // Send the input to tmux
855        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; // Go to fullscreen to see result
870                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    /// Restart the selected agent (kill and respawn claude)
887    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        // Find window
901        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            // Send Ctrl+C to interrupt current process
906            let _ = Command::new("tmux")
907                .args(["send-keys", "-t", &target, "C-c"])
908                .output();
909
910            // Small delay
911            std::thread::sleep(Duration::from_millis(200));
912
913            // Clear and show message
914            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    /// Toggle help overlay
932    pub fn toggle_help(&mut self) {
933        self.show_help = !self.show_help;
934    }
935
936    /// Scroll terminal output up (show older content)
937    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    /// Scroll terminal output down (show newer content)
944    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    /// Jump to bottom of terminal output
952    pub fn scroll_to_bottom(&mut self) {
953        self.scroll_offset = 0;
954        self.auto_scroll = true;
955    }
956
957    /// Reset scroll when switching agents
958    fn reset_scroll(&mut self) {
959        self.scroll_offset = 0;
960        self.auto_scroll = true;
961    }
962
963    /// Get status counts (starting, running, completed, failed)
964    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    /// Get the selected agent (if any)
986    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    // === Wave-related methods ===
996
997    /// Refresh waves data from phases
998    pub fn refresh_waves(&mut self) {
999        // Reload phases from storage
1000        let storage = Storage::new(self.project_root.clone());
1001        self.phases = storage.load_tasks().unwrap_or_default();
1002
1003        // In swarm mode, use actual wave data from swarm session
1004        if self.swarm_mode {
1005            self.waves = self.compute_swarm_waves();
1006            // Update swarm progress after computing waves (now that phases are fresh)
1007            self.swarm_progress = self.compute_swarm_progress();
1008            return;
1009        }
1010
1011        // Get running agent task IDs
1012        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        // Determine which tag to use
1020        let tag = self.active_tag.clone().or_else(|| {
1021            // Try to get tag from session
1022            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        // Build waves using topological sort
1036        self.waves = self.compute_waves(phase, &running_task_ids);
1037    }
1038
1039    /// Compute swarm progress from session data and phases
1040    ///
1041    /// This aggregates progress information from the swarm session and task phases
1042    /// into a consolidated SwarmProgress struct for easy access by the UI.
1043    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        // Find current wave (first incomplete wave, or last wave if all complete)
1050        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        // Count tasks by status from phase data
1058        let (tasks_completed, tasks_in_progress, tasks_failed, tasks_total) =
1059            if let Some(phase) = phase {
1060                // Get all task IDs from swarm waves
1061                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                // Fall back to counting from swarm session
1083                let total = swarm.total_tasks();
1084                let failed = swarm.total_failures();
1085                (0, 0, failed, total)
1086            };
1087
1088        // Count validation results
1089        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        // Count total repairs
1100        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    /// Compute waves from swarm session data (shows actual execution waves)
1116    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                // Collect all task IDs from all rounds in this wave
1129                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                        // Try to get task info from phase
1139                        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                        // Determine state based on task status and wave completion
1156                        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                                    // Wave completed but task still pending = failed/blocked
1163                                    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    /// Compute execution waves for a phase
1191    fn compute_waves(&self, phase: &Phase, running_task_ids: &HashSet<String>) -> Vec<Wave> {
1192        // Collect actionable tasks
1193        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            // Skip parent tasks that have subtasks - only subtasks should be spawned
1203            if !task.subtasks.is_empty() {
1204                continue;
1205            }
1206
1207            // If subtask, only include if parent is expanded
1208            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        // Build dependency graph
1226        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                    // Internal dependency - track in graph
1236                    *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                    // External dependency - check if satisfied
1243                    // If not satisfied (e.g., Expanded with incomplete subtasks), block this task
1244                    if !self.is_dependency_satisfied(dep, phase) {
1245                        // Mark as blocked by setting very high in_degree
1246                        *in_degree.entry(task.id.clone()).or_insert(0) += 1000;
1247                    }
1248                }
1249            }
1250        }
1251
1252        // Kahn's algorithm with wave tracking
1253        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)| deg == 0)
1261                .map(|(id, _)| id.clone())
1262                .collect();
1263
1264            if ready.is_empty() {
1265                break; // Circular dependency
1266            }
1267
1268            // Sort for stable display order
1269            ready.sort();
1270
1271            // Build wave tasks with state
1272            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            // Remove ready tasks and update dependents
1303            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                // Sort tasks by ID for stable display
1316                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    /// Check if a task is ready (dependencies met, pending)
1329    fn is_task_ready(&self, task: &Task, phase: &Phase) -> bool {
1330        if task.status != TaskStatus::Pending {
1331            return false;
1332        }
1333
1334        // Check all dependencies are satisfied
1335        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    /// Check if a dependency is satisfied (Done, or Expanded with all subtasks done)
1345    fn is_dependency_satisfied(&self, dep_id: &str, phase: &Phase) -> bool {
1346        let Some(dep) = phase.get_task(dep_id) else {
1347            return true; // If dep not found, assume external/done
1348        };
1349
1350        match dep.status {
1351            TaskStatus::Done => true,
1352            TaskStatus::Expanded => {
1353                // Expanded task is only satisfied if all its subtasks are done
1354                if dep.subtasks.is_empty() {
1355                    false // Expanded with no subtasks = not done yet
1356                } 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, // Pending, InProgress, Blocked, Cancelled = not satisfied
1366        }
1367    }
1368
1369    /// Get flat list of all tasks in waves for navigation
1370    pub fn all_wave_tasks(&self) -> Vec<&WaveTask> {
1371        self.waves.iter().flat_map(|w| w.tasks.iter()).collect()
1372    }
1373
1374    /// Get currently selected wave task
1375    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    // === Panel navigation ===
1381
1382    /// Switch focus to next panel
1383    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    /// Switch focus to previous panel
1392    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    /// Move selection up in current panel
1401    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    /// Move selection down in current panel
1415    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    /// Adjust wave scroll offset to keep selected item visible
1430    /// Assumes visible height of ~4 items (plus wave headers)
1431    fn adjust_wave_scroll(&mut self) {
1432        // Calculate the line index of the current task
1433        // Each wave has 1 header line, then tasks
1434        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; // wave header
1440            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        // Visible height is approximately 4-5 lines in the waves panel
1454        let visible_height = 4;
1455
1456        // Scroll to keep current line visible
1457        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    // === Task selection for spawning ===
1465
1466    /// Toggle selection of currently highlighted task
1467    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                // Only allow selecting ready tasks
1474                if task.state == WaveTaskState::Ready {
1475                    self.selected_tasks.insert(task_id);
1476                }
1477            }
1478        }
1479    }
1480
1481    /// Select all ready tasks in waves
1482    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    /// Clear all task selections
1493    pub fn clear_selection(&mut self) {
1494        self.selected_tasks.clear();
1495    }
1496
1497    /// Get count of ready tasks
1498    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    /// Get count of selected tasks
1507    pub fn selected_task_count(&self) -> usize {
1508        self.selected_tasks.len()
1509    }
1510
1511    /// Get selected tasks for spawning
1512    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    /// Spawn the selected tasks
1520    /// Returns the number of tasks successfully spawned
1521    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        // Get working directory
1535        let working_dir = self
1536            .project_root
1537            .clone()
1538            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1539
1540        // Get session info
1541        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        // Load phase to get full task data
1553        let storage = Storage::new(self.project_root.clone());
1554
1555        for (task_id, task_title, tag) in &tasks_to_spawn {
1556            // Get full task from phase
1557            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            // Generate prompt
1568            let prompt = agent::generate_prompt(task, tag);
1569
1570            // Spawn in tmux (always use tmux from TUI since we're in a session)
1571            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                    // Add to session agents
1577                    if let Some(ref mut session) = self.session {
1578                        session.add_agent(task_id, task_title, tag);
1579                    }
1580
1581                    // Mark task as in-progress
1582                    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            // Small delay between spawns
1595            if spawned_count < tasks_to_spawn.len() {
1596                std::thread::sleep(Duration::from_millis(300));
1597            }
1598        }
1599
1600        // Save updated session
1601        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            // Clear selection and refresh
1610            self.selected_tasks.clear();
1611            self.refresh()?;
1612            self.refresh_waves();
1613        }
1614
1615        Ok(spawned_count)
1616    }
1617
1618    /// Prepare to start swarm - returns swarm command and tag
1619    pub fn prepare_swarm_start(&self) -> Option<(String, String)> {
1620        // Get tag from session or active tag
1621        let tag = self
1622            .session
1623            .as_ref()
1624            .map(|s| s.tag.clone())
1625            .or_else(|| self.active_tag.clone())?;
1626
1627        // Build swarm command
1628        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    /// Update the status of the currently selected agent's task
1635    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        // Update task status in storage
1652        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                // Show confirmation
1661                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        // Refresh to show updated status
1670        self.refresh()?;
1671        self.refresh_waves();
1672
1673        Ok(())
1674    }
1675
1676    /// Spawn a single task with Ralph loop enabled
1677    /// The agent will keep trying until the task is marked done
1678    fn spawn_task_with_ralph(&mut self, task_id: &str) -> Result<()> {
1679        use crate::commands::spawn::{agent, terminal};
1680
1681        // Find the task in waves
1682        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        // Get working directory
1695        let working_dir = self
1696            .project_root
1697            .clone()
1698            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1699
1700        // Get session info
1701        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        // Load phase to get full task data
1712        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        // Generate prompt with Ralph loop instructions
1725        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        // Spawn in tmux with Ralph loop wrapper
1757        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                // Add to session agents
1761                if let Some(ref mut session) = self.session {
1762                    session.add_agent(&task_id, &task_title, &tag);
1763                }
1764
1765                // Mark task as in-progress
1766                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                // Save session
1774                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                // Refresh
1782                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}