An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.
at main 377 lines 16 kB view raw
1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 2 3use crate::config::Config; 4use crate::tui::messages::{AgentMessage, AgentSender}; 5use crate::tui::views::{ 6 DashboardMode, DashboardState, ExecutionState, MessageRole, NavDirection, OutputItem, 7 PlanningState, ToolCall, 8}; 9use crate::tui::widgets::{HelpOverlay, SidePanel, Spinner}; 10 11#[derive(Debug, Clone, Copy, PartialEq, Eq)] 12pub enum ActiveTab { 13 Dashboard, 14 Planning, 15 Execution, 16} 17 18pub struct App { 19 pub running: bool, 20 pub active_tab: ActiveTab, 21 pub dashboard: DashboardState, 22 pub planning: PlanningState, 23 pub execution: ExecutionState, 24 pub side_panel: SidePanel, 25 pub spec_dir: String, 26 pub agent_tx: AgentSender, 27 pub config: Option<Config>, 28 pub spinner: Spinner, 29 pub help: HelpOverlay, 30 pub planning_active: bool, 31 pub execution_active: bool, 32} 33 34impl App { 35 pub fn new(spec_dir: &str, agent_tx: AgentSender, config: Option<Config>) -> Self { 36 let mut dashboard = DashboardState::new(); 37 dashboard.load_specs(spec_dir); 38 39 Self { 40 running: true, 41 active_tab: ActiveTab::Dashboard, 42 dashboard, 43 planning: PlanningState::new(), 44 execution: ExecutionState::new(), 45 side_panel: SidePanel::new(), 46 spec_dir: spec_dir.to_string(), 47 agent_tx, 48 config, 49 spinner: Spinner::new(), 50 help: HelpOverlay::new(), 51 planning_active: false, 52 execution_active: false, 53 } 54 } 55 56 /// Handle key events. Uses full KeyEvent to preserve modifiers. 57 pub fn handle_key(&mut self, key: KeyEvent) { 58 // Help overlay is modal - only Esc closes it 59 if self.help.visible { 60 if key.code == KeyCode::Esc { 61 self.help.visible = false; 62 } 63 return; 64 } 65 66 // Handle planning insert mode separately 67 if self.active_tab == ActiveTab::Planning && self.planning.insert_mode { 68 match key.code { 69 KeyCode::Esc => { 70 self.planning.insert_mode = false; 71 } 72 KeyCode::Enter => { 73 if let Some(text) = self.planning.submit_input() { 74 self.planning.add_message(MessageRole::User, text.clone()); 75 76 // Don't spawn if already running 77 if self.planning.thinking { 78 return; 79 } 80 81 // Spawn planning agent if we have config 82 if let Some(ref config) = self.config { 83 self.planning.thinking = true; 84 let tx = self.agent_tx.clone(); 85 let spec_dir = self.spec_dir.clone(); 86 let config = config.clone(); 87 88 tokio::spawn(async move { 89 match crate::planning::PlanningAgent::new(config, spec_dir) { 90 Ok(mut agent) => { 91 if let Err(e) = 92 agent.run_with_sender(tx.clone(), text).await 93 { 94 let _ = tx 95 .send(AgentMessage::PlanningError(e.to_string())) 96 .await; 97 } 98 } 99 Err(e) => { 100 let _ = tx 101 .send(AgentMessage::PlanningError(e.to_string())) 102 .await; 103 } 104 } 105 }); 106 } 107 } 108 } 109 _ => { 110 self.planning.input.input(key); 111 } 112 } 113 return; 114 } 115 116 match (key.code, key.modifiers) { 117 (KeyCode::Char('c'), KeyModifiers::CONTROL) => self.running = false, 118 (KeyCode::Char('q'), KeyModifiers::NONE) => self.running = false, 119 (KeyCode::Char('1'), KeyModifiers::NONE) => self.active_tab = ActiveTab::Dashboard, 120 (KeyCode::Char('2'), KeyModifiers::NONE) => self.active_tab = ActiveTab::Planning, 121 (KeyCode::Char('3'), KeyModifiers::NONE) => self.active_tab = ActiveTab::Execution, 122 (KeyCode::Tab, _) => { 123 self.active_tab = match self.active_tab { 124 ActiveTab::Dashboard => ActiveTab::Planning, 125 ActiveTab::Planning => ActiveTab::Execution, 126 ActiveTab::Execution => ActiveTab::Dashboard, 127 }; 128 } 129 (KeyCode::Char('K'), KeyModifiers::SHIFT) 130 if self.active_tab == ActiveTab::Dashboard => 131 { 132 self.dashboard.mode = DashboardMode::Kanban; 133 } 134 (KeyCode::Char('A'), KeyModifiers::SHIFT) 135 if self.active_tab == ActiveTab::Dashboard => 136 { 137 self.dashboard.mode = DashboardMode::Activity; 138 } 139 (KeyCode::Up, KeyModifiers::NONE) | (KeyCode::Char('k'), KeyModifiers::NONE) 140 if self.active_tab == ActiveTab::Dashboard => 141 { 142 self.dashboard.move_selection(NavDirection::Up); 143 } 144 (KeyCode::Down, KeyModifiers::NONE) | (KeyCode::Char('j'), KeyModifiers::NONE) 145 if self.active_tab == ActiveTab::Dashboard => 146 { 147 self.dashboard.move_selection(NavDirection::Down); 148 } 149 (KeyCode::Left, KeyModifiers::NONE) | (KeyCode::Char('h'), KeyModifiers::NONE) 150 if self.active_tab == ActiveTab::Dashboard => 151 { 152 self.dashboard.move_selection(NavDirection::Left); 153 } 154 (KeyCode::Right, KeyModifiers::NONE) | (KeyCode::Char('l'), KeyModifiers::NONE) 155 if self.active_tab == ActiveTab::Dashboard => 156 { 157 self.dashboard.move_selection(NavDirection::Right); 158 } 159 (KeyCode::Enter, KeyModifiers::NONE) if self.active_tab == ActiveTab::Dashboard => { 160 // Don't spawn if execution already running 161 if self.execution.running { 162 return; 163 } 164 165 if let Some(spec) = self.dashboard.selected_spec() { 166 let spec_path = spec.path.clone(); 167 168 if let Some(ref config) = self.config { 169 let tx = self.agent_tx.clone(); 170 let config = config.clone(); 171 172 // Switch to execution tab 173 self.active_tab = ActiveTab::Execution; 174 175 tokio::spawn(async move { 176 match crate::ralph::RalphLoop::new(config, spec_path, None) { 177 Ok(ralph) => { 178 if let Err(e) = ralph.run_with_sender(tx.clone()).await { 179 let _ = tx 180 .send(AgentMessage::ExecutionError(e.to_string())) 181 .await; 182 } 183 } 184 Err(e) => { 185 let _ = 186 tx.send(AgentMessage::ExecutionError(e.to_string())).await; 187 } 188 } 189 }); 190 } 191 } 192 } 193 (KeyCode::Char('i'), KeyModifiers::NONE) if self.active_tab == ActiveTab::Planning => { 194 self.planning.insert_mode = true; 195 } 196 // Planning tab scrolling (not in insert mode) 197 (KeyCode::Up, KeyModifiers::NONE) | (KeyCode::Char('k'), KeyModifiers::NONE) 198 if self.active_tab == ActiveTab::Planning && !self.planning.insert_mode => 199 { 200 self.planning.scroll_up(1); 201 } 202 (KeyCode::Down, KeyModifiers::NONE) | (KeyCode::Char('j'), KeyModifiers::NONE) 203 if self.active_tab == ActiveTab::Planning && !self.planning.insert_mode => 204 { 205 self.planning.scroll_down(1); 206 } 207 (KeyCode::PageUp, KeyModifiers::NONE) 208 if self.active_tab == ActiveTab::Planning && !self.planning.insert_mode => 209 { 210 let page_size = self.planning.viewport_height.saturating_sub(1).max(1); 211 self.planning.scroll_up(page_size); 212 } 213 (KeyCode::PageDown, KeyModifiers::NONE) 214 if self.active_tab == ActiveTab::Planning && !self.planning.insert_mode => 215 { 216 let page_size = self.planning.viewport_height.saturating_sub(1).max(1); 217 self.planning.scroll_down(page_size); 218 } 219 (KeyCode::Home, KeyModifiers::NONE) 220 if self.active_tab == ActiveTab::Planning && !self.planning.insert_mode => 221 { 222 self.planning.scroll_offset = 0; 223 self.planning.auto_scroll = false; 224 } 225 (KeyCode::End, KeyModifiers::NONE) 226 if self.active_tab == ActiveTab::Planning && !self.planning.insert_mode => 227 { 228 self.planning.scroll_to_bottom(); 229 } 230 // Execution tab scrolling 231 (KeyCode::Up, KeyModifiers::NONE) | (KeyCode::Char('k'), KeyModifiers::NONE) 232 if self.active_tab == ActiveTab::Execution => 233 { 234 self.execution.scroll_up(1); 235 } 236 (KeyCode::Down, KeyModifiers::NONE) | (KeyCode::Char('j'), KeyModifiers::NONE) 237 if self.active_tab == ActiveTab::Execution => 238 { 239 self.execution.scroll_down(1); 240 } 241 (KeyCode::PageUp, KeyModifiers::NONE) if self.active_tab == ActiveTab::Execution => { 242 let page_size = self.execution.viewport_height.saturating_sub(1).max(1); 243 self.execution.scroll_up(page_size); 244 } 245 (KeyCode::PageDown, KeyModifiers::NONE) if self.active_tab == ActiveTab::Execution => { 246 let page_size = self.execution.viewport_height.saturating_sub(1).max(1); 247 self.execution.scroll_down(page_size); 248 } 249 (KeyCode::Home, KeyModifiers::NONE) if self.active_tab == ActiveTab::Execution => { 250 self.execution.scroll_offset = 0; 251 self.execution.auto_scroll = false; 252 } 253 (KeyCode::End, KeyModifiers::NONE) if self.active_tab == ActiveTab::Execution => { 254 self.execution.scroll_to_bottom(); 255 } 256 (KeyCode::Char('['), KeyModifiers::NONE) | (KeyCode::Char(']'), KeyModifiers::NONE) => { 257 self.side_panel.toggle(); 258 } 259 (KeyCode::Char('?'), KeyModifiers::NONE) => { 260 self.help.toggle(); 261 } 262 (KeyCode::Esc, _) => { 263 if self.side_panel.visible { 264 self.side_panel.visible = false; 265 } 266 } 267 _ => {} 268 } 269 } 270 271 pub fn handle_agent_message(&mut self, msg: AgentMessage) { 272 match msg { 273 // Planning messages 274 AgentMessage::PlanningStarted => { 275 self.planning.thinking = true; 276 } 277 AgentMessage::PlanningResponse(text) => { 278 self.planning.thinking = false; 279 self.planning.add_message(MessageRole::Assistant, text); 280 } 281 AgentMessage::PlanningToolCall { name, args: _ } => { 282 self.planning 283 .add_message(MessageRole::Assistant, format!("[Calling tool: {}]", name)); 284 } 285 AgentMessage::PlanningToolResult { name, output } => { 286 let preview = if output.len() > 100 { 287 format!("{}...", &output[..100]) 288 } else { 289 output 290 }; 291 self.planning.add_message( 292 MessageRole::Assistant, 293 format!("[{} result: {}]", name, preview), 294 ); 295 } 296 AgentMessage::PlanningComplete { spec_path } => { 297 self.planning.thinking = false; 298 self.planning.add_message( 299 MessageRole::Assistant, 300 format!("✓ Spec saved to {}", spec_path), 301 ); 302 self.dashboard.load_specs(&self.spec_dir); 303 } 304 AgentMessage::PlanningError(err) => { 305 self.planning.thinking = false; 306 self.planning 307 .add_message(MessageRole::Assistant, format!("Error: {}", err)); 308 } 309 310 // Execution messages 311 AgentMessage::ExecutionStarted { spec_path: _ } => { 312 self.execution.running = true; 313 self.execution.output.clear(); 314 } 315 AgentMessage::TaskStarted { task_id: _, title } => { 316 self.execution.add_output(OutputItem::Message { 317 role: "System".to_string(), 318 content: format!("Starting task: {}", title), 319 }); 320 } 321 AgentMessage::TaskResponse(text) => { 322 self.execution.add_output(OutputItem::Message { 323 role: "Assistant".to_string(), 324 content: text, 325 }); 326 } 327 AgentMessage::TaskToolCall { name, args } => { 328 self.execution.add_output(OutputItem::ToolCall(ToolCall { 329 name, 330 output: format!("Args: {}", args), 331 collapsed: true, 332 })); 333 } 334 AgentMessage::TaskToolResult { name, output } => { 335 self.execution.add_output(OutputItem::ToolCall(ToolCall { 336 name, 337 output, 338 collapsed: false, 339 })); 340 } 341 AgentMessage::TaskComplete { task_id } => { 342 self.execution.add_output(OutputItem::Message { 343 role: "System".to_string(), 344 content: format!("✓ Task {} complete", task_id), 345 }); 346 } 347 AgentMessage::TaskBlocked { task_id, reason } => { 348 self.execution.add_output(OutputItem::Message { 349 role: "System".to_string(), 350 content: format!("✗ Task {} blocked: {}", task_id, reason), 351 }); 352 } 353 AgentMessage::ExecutionComplete => { 354 self.execution.running = false; 355 self.execution.add_output(OutputItem::Message { 356 role: "System".to_string(), 357 content: "Execution complete".to_string(), 358 }); 359 self.dashboard.load_specs(&self.spec_dir); 360 } 361 AgentMessage::ExecutionError(err) => { 362 self.execution.running = false; 363 self.execution.add_output(OutputItem::Message { 364 role: "Error".to_string(), 365 content: err, 366 }); 367 } 368 } 369 } 370} 371 372impl Default for App { 373 fn default() -> Self { 374 let (tx, _) = crate::tui::messages::agent_channel(); 375 Self::new("", tx, None) 376 } 377}