Skip to main content

scud/commands/spawn/tui/
mod.rs

1//! TUI module for spawn session monitoring
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
10pub mod agents;
11pub mod app;
12pub mod components;
13pub mod header;
14pub mod output;
15pub mod theme;
16pub mod ui;
17pub mod waves;
18
19use anyhow::Result;
20use crossterm::{
21    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
22    execute,
23    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
24};
25use ratatui::prelude::*;
26use std::io;
27use std::path::PathBuf;
28use std::process::Command;
29use std::time::Duration;
30
31use self::app::{App, FocusedPanel, ViewMode};
32use self::ui::render;
33
34/// Result of the TUI app exit
35enum AppExit {
36    /// Normal quit
37    Quit,
38    /// Start swarm in tmux
39    StartSwarm {
40        command: String,
41        tag: String,
42        session_name: String,
43    },
44}
45
46/// Run the TUI monitor
47pub fn run(project_root: Option<PathBuf>, session_name: &str, swarm_mode: bool) -> Result<()> {
48    // Setup terminal
49    enable_raw_mode()?;
50    let mut stdout = io::stdout();
51    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
52    let backend = CrosstermBackend::new(stdout);
53    let mut terminal = Terminal::new(backend)?;
54
55    // Create app state
56    let mut app = App::new(project_root.clone(), session_name, swarm_mode)?;
57
58    // Main loop
59    let result = run_app(&mut terminal, &mut app);
60
61    // Restore terminal
62    disable_raw_mode()?;
63    execute!(
64        terminal.backend_mut(),
65        LeaveAlternateScreen,
66        DisableMouseCapture
67    )?;
68    terminal.show_cursor()?;
69
70    // Handle result
71    match result? {
72        AppExit::Quit => Ok(()),
73        AppExit::StartSwarm {
74            command,
75            tag,
76            session_name,
77        } => {
78            use colored::Colorize;
79
80            // Print swarm start message
81            println!();
82            println!("{}", Colorize::bold(Colorize::cyan("Starting swarm...")));
83            println!("Tag: {}", Colorize::green(tag.as_str()));
84            println!();
85
86            // Spawn swarm in tmux window
87            let window_name = format!("swarm-{}", tag);
88            let tmux_session = session_name.clone();
89
90            // Build script to run in tmux
91            let script = format!(
92                "cd {} && {}",
93                project_root
94                    .as_ref()
95                    .and_then(|p| p.to_str())
96                    .unwrap_or("."),
97                command
98            );
99
100            let status = Command::new("tmux")
101                .args([
102                    "new-window",
103                    "-t",
104                    &tmux_session,
105                    "-n",
106                    &window_name,
107                    "bash",
108                    "-c",
109                    &format!("{}; read -p 'Press enter to close...'", script),
110                ])
111                .status();
112
113            match status {
114                Ok(s) if s.success() => {
115                    println!(
116                        "Swarm started in tmux window: {}:{}",
117                        tmux_session, window_name
118                    );
119                    println!();
120                    let attach_cmd = format!("tmux attach -t {}", tmux_session);
121                    println!("To attach: {}", Colorize::cyan(attach_cmd.as_str()));
122                    let monitor_cmd = format!("scud monitor --swarm --session {}", session_name);
123                    println!("To monitor: {}", Colorize::cyan(monitor_cmd.as_str()));
124                }
125                _ => {
126                    println!("{}", Colorize::red("Failed to start swarm in tmux"));
127                    println!("Run manually: {}", Colorize::yellow(command.as_str()));
128                }
129            }
130
131            Ok(())
132        }
133    }
134}
135
136fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<AppExit> {
137    loop {
138        // Draw UI
139        terminal.draw(|frame| render(frame, app))?;
140
141        // Poll for events with timeout (allows periodic refresh)
142        if event::poll(Duration::from_millis(100))? {
143            if let Event::Key(key) = event::read()? {
144                // Handle help overlay first
145                if app.show_help {
146                    match key.code {
147                        KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => {
148                            app.show_help = false;
149                        }
150                        _ => {}
151                    }
152                    continue;
153                }
154
155                // Handle input mode separately
156                if app.view_mode == ViewMode::Input {
157                    match key.code {
158                        KeyCode::Esc => app.exit_fullscreen(),
159                        KeyCode::Enter => app.send_input()?,
160                        KeyCode::Backspace => app.input_backspace(),
161                        KeyCode::Char(c) => app.input_char(c),
162                        _ => {}
163                    }
164                    continue;
165                }
166
167                // Normal mode key handling
168                match (key.modifiers, key.code) {
169                    // Quit
170                    (_, KeyCode::Char('q')) | (KeyModifiers::CONTROL, KeyCode::Char('c')) => {
171                        return Ok(AppExit::Quit);
172                    }
173
174                    // Tab: switch panel focus
175                    (_, KeyCode::Tab) => app.next_panel(),
176                    (KeyModifiers::SHIFT, KeyCode::BackTab) => app.previous_panel(),
177
178                    // j/k: navigate within current panel
179                    (_, KeyCode::Char('k')) | (_, KeyCode::Up) => {
180                        if app.view_mode == ViewMode::Fullscreen {
181                            app.scroll_up(1);
182                        } else {
183                            app.move_up();
184                        }
185                    }
186                    (_, KeyCode::Char('j')) | (_, KeyCode::Down) => {
187                        if app.view_mode == ViewMode::Fullscreen {
188                            app.scroll_down(1);
189                        } else {
190                            app.move_down();
191                        }
192                    }
193
194                    (_, KeyCode::PageUp) => app.scroll_up(10),
195                    (_, KeyCode::PageDown) => app.scroll_down(10),
196
197                    // G: jump to bottom (like vim)
198                    (KeyModifiers::SHIFT, KeyCode::Char('G')) | (_, KeyCode::Char('G')) => {
199                        app.scroll_to_bottom();
200                    }
201                    // g: jump to top
202                    (_, KeyCode::Char('g')) => app.scroll_up(usize::MAX),
203
204                    // Space: toggle task selection (in waves panel)
205                    (_, KeyCode::Char(' ')) => {
206                        if app.focused_panel == FocusedPanel::Waves {
207                            app.toggle_task_selection();
208                        }
209                    }
210
211                    // a: select all ready tasks
212                    (_, KeyCode::Char('a')) => {
213                        if app.focused_panel == FocusedPanel::Waves {
214                            app.select_all_ready();
215                        }
216                    }
217
218                    // c: clear selection
219                    (_, KeyCode::Char('c')) => {
220                        if app.focused_panel == FocusedPanel::Waves {
221                            app.clear_selection();
222                        }
223                    }
224
225                    // s: spawn selected tasks
226                    (_, KeyCode::Char('s')) => {
227                        if app.focused_panel == FocusedPanel::Waves && app.selected_task_count() > 0
228                        {
229                            let count = app.selected_task_count();
230                            match app.spawn_selected_tasks() {
231                                Ok(spawned) if spawned > 0 => {
232                                    app.error = None;
233                                    // Switch to agents panel to see the new agents
234                                    app.focused_panel = FocusedPanel::Agents;
235                                }
236                                Ok(_) => {
237                                    app.error = Some(format!("Failed to spawn {} tasks", count));
238                                }
239                                Err(e) => {
240                                    app.error = Some(format!("Spawn error: {}", e));
241                                }
242                            }
243                        }
244                    }
245
246                    // Enter: toggle fullscreen or view agent output
247                    (_, KeyCode::Enter) => {
248                        if app.focused_panel == FocusedPanel::Output
249                            || app.view_mode == ViewMode::Fullscreen
250                        {
251                            app.toggle_fullscreen();
252                        } else if app.focused_panel == FocusedPanel::Agents {
253                            // Switch to output panel to see agent's output
254                            app.focused_panel = FocusedPanel::Output;
255                            app.refresh_live_output();
256                        }
257                    }
258
259                    // Esc: exit fullscreen or do nothing in split
260                    (_, KeyCode::Esc) => {
261                        if app.view_mode == ViewMode::Fullscreen {
262                            app.exit_fullscreen();
263                        }
264                    }
265
266                    // i: Enter input mode (send text to agent)
267                    (_, KeyCode::Char('i')) => {
268                        if app.focused_panel == FocusedPanel::Agents
269                            || app.focused_panel == FocusedPanel::Output
270                        {
271                            app.enter_input_mode();
272                        }
273                    }
274
275                    // x: Stop/interrupt agent (Ctrl+C)
276                    (_, KeyCode::Char('x')) => {
277                        if app.focused_panel == FocusedPanel::Agents {
278                            app.restart_agent()?;
279                        }
280                    }
281
282                    // Refresh
283                    (_, KeyCode::Char('r')) => {
284                        app.refresh()?;
285                        app.refresh_waves();
286                        app.refresh_live_output();
287                    }
288
289                    // Help
290                    (_, KeyCode::Char('?')) => app.toggle_help(),
291
292                    // R: Toggle Ralph mode (autonomous wave execution)
293                    (KeyModifiers::SHIFT, KeyCode::Char('R')) | (_, KeyCode::Char('R')) => {
294                        app.toggle_ralph_mode();
295                    }
296
297                    // d: Mark task as Done (in Agents panel)
298                    (_, KeyCode::Char('d')) => {
299                        if app.focused_panel == FocusedPanel::Agents {
300                            let _ =
301                                app.set_selected_task_status(crate::models::task::TaskStatus::Done);
302                        }
303                    }
304
305                    // p: Mark task as Pending (in Agents panel)
306                    (_, KeyCode::Char('p')) => {
307                        if app.focused_panel == FocusedPanel::Agents {
308                            let _ = app
309                                .set_selected_task_status(crate::models::task::TaskStatus::Pending);
310                        }
311                    }
312
313                    // b: Mark task as Blocked (in Agents panel)
314                    (_, KeyCode::Char('b')) => {
315                        if app.focused_panel == FocusedPanel::Agents {
316                            let _ = app
317                                .set_selected_task_status(crate::models::task::TaskStatus::Blocked);
318                        }
319                    }
320
321                    // W: Start swarm (exits TUI and spawns swarm in tmux)
322                    (KeyModifiers::SHIFT, KeyCode::Char('W')) | (_, KeyCode::Char('W')) => {
323                        if let Some((cmd, tag)) = app.prepare_swarm_start() {
324                            return Ok(AppExit::StartSwarm {
325                                command: cmd,
326                                tag,
327                                session_name: app.session_name.clone(),
328                            });
329                        } else {
330                            app.error = Some("No tag available for swarm".to_string());
331                        }
332                    }
333
334                    _ => {}
335                }
336            }
337        }
338
339        // Periodic tick (refreshes output and status)
340        app.tick()?;
341    }
342}