An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.
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}