1pub 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
34enum AppExit {
36 Quit,
38 StartSwarm {
40 command: String,
41 tag: String,
42 session_name: String,
43 },
44}
45
46pub fn run(project_root: Option<PathBuf>, session_name: &str, swarm_mode: bool) -> Result<()> {
48 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 let mut app = App::new(project_root.clone(), session_name, swarm_mode)?;
57
58 let result = run_app(&mut terminal, &mut app);
60
61 disable_raw_mode()?;
63 execute!(
64 terminal.backend_mut(),
65 LeaveAlternateScreen,
66 DisableMouseCapture
67 )?;
68 terminal.show_cursor()?;
69
70 match result? {
72 AppExit::Quit => Ok(()),
73 AppExit::StartSwarm {
74 command,
75 tag,
76 session_name,
77 } => {
78 use colored::Colorize;
79
80 println!();
82 println!("{}", Colorize::bold(Colorize::cyan("Starting swarm...")));
83 println!("Tag: {}", Colorize::green(tag.as_str()));
84 println!();
85
86 let window_name = format!("swarm-{}", tag);
88 let tmux_session = session_name.clone();
89
90 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 terminal.draw(|frame| render(frame, app))?;
140
141 if event::poll(Duration::from_millis(100))? {
143 if let Event::Key(key) = event::read()? {
144 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 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 match (key.modifiers, key.code) {
169 (_, KeyCode::Char('q')) | (KeyModifiers::CONTROL, KeyCode::Char('c')) => {
171 return Ok(AppExit::Quit);
172 }
173
174 (_, KeyCode::Tab) => app.next_panel(),
176 (KeyModifiers::SHIFT, KeyCode::BackTab) => app.previous_panel(),
177
178 (_, 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 (KeyModifiers::SHIFT, KeyCode::Char('G')) | (_, KeyCode::Char('G')) => {
199 app.scroll_to_bottom();
200 }
201 (_, KeyCode::Char('g')) => app.scroll_up(usize::MAX),
203
204 (_, KeyCode::Char(' ')) => {
206 if app.focused_panel == FocusedPanel::Waves {
207 app.toggle_task_selection();
208 }
209 }
210
211 (_, KeyCode::Char('a')) => {
213 if app.focused_panel == FocusedPanel::Waves {
214 app.select_all_ready();
215 }
216 }
217
218 (_, KeyCode::Char('c')) => {
220 if app.focused_panel == FocusedPanel::Waves {
221 app.clear_selection();
222 }
223 }
224
225 (_, 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 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 (_, 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 app.focused_panel = FocusedPanel::Output;
255 app.refresh_live_output();
256 }
257 }
258
259 (_, KeyCode::Esc) => {
261 if app.view_mode == ViewMode::Fullscreen {
262 app.exit_fullscreen();
263 }
264 }
265
266 (_, 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 (_, KeyCode::Char('x')) => {
277 if app.focused_panel == FocusedPanel::Agents {
278 app.restart_agent()?;
279 }
280 }
281
282 (_, KeyCode::Char('r')) => {
284 app.refresh()?;
285 app.refresh_waves();
286 app.refresh_live_output();
287 }
288
289 (_, KeyCode::Char('?')) => app.toggle_help(),
291
292 (KeyModifiers::SHIFT, KeyCode::Char('R')) | (_, KeyCode::Char('R')) => {
294 app.toggle_ralph_mode();
295 }
296
297 (_, 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 (_, 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 (_, 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 (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 app.tick()?;
341 }
342}