An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.
at main 313 lines 10 kB view raw
1use ratatui::{ 2 Frame, 3 layout::{Constraint, Direction, Layout, Rect}, 4 style::{Color, Modifier, Style}, 5 text::{Line, Span}, 6 widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, 7}; 8 9use crate::spec::{Task, TaskStatus}; 10 11#[derive(Debug, Clone)] 12pub struct ToolCall { 13 pub name: String, 14 pub output: String, 15 pub collapsed: bool, 16} 17 18#[derive(Debug, Clone)] 19pub enum OutputItem { 20 Message { role: String, content: String }, 21 ToolCall(ToolCall), 22} 23 24const MAX_OUTPUT_ITEMS: usize = 1000; 25 26pub struct ExecutionState { 27 pub running: bool, 28 pub current_task: Option<Task>, 29 pub tasks: Vec<Task>, 30 pub output: Vec<OutputItem>, 31 pub scroll_offset: usize, 32 pub auto_scroll: bool, 33 pub scroll_to_bottom_pending: bool, 34 pub content_height: usize, 35 pub viewport_height: usize, 36 pub last_area_width: u16, 37} 38 39impl ExecutionState { 40 pub fn new() -> Self { 41 Self { 42 running: false, 43 current_task: None, 44 tasks: Vec::new(), 45 output: Vec::new(), 46 scroll_offset: 0, 47 auto_scroll: true, 48 scroll_to_bottom_pending: false, 49 content_height: 0, 50 viewport_height: 20, 51 last_area_width: 0, 52 } 53 } 54 55 pub fn task_progress(&self) -> (usize, usize) { 56 let completed = self 57 .tasks 58 .iter() 59 .filter(|t| t.status == TaskStatus::Complete) 60 .count(); 61 (completed, self.tasks.len()) 62 } 63 64 pub fn add_output(&mut self, item: OutputItem) { 65 self.output.push(item); 66 if self.output.len() > MAX_OUTPUT_ITEMS { 67 self.output.remove(0); 68 // Note: scroll_offset will be recalculated on next render 69 // based on actual content height, so this removal is handled 70 } 71 if self.auto_scroll { 72 // Set flag to scroll on next render 73 self.scroll_to_bottom_pending = true; 74 } 75 } 76 77 pub fn clamp_scroll(&mut self, content_height: usize, viewport_height: usize) { 78 let max_scroll = content_height.saturating_sub(viewport_height); 79 self.scroll_offset = self.scroll_offset.min(max_scroll); 80 } 81 82 pub fn scroll_up(&mut self, lines: usize) { 83 self.scroll_offset = self.scroll_offset.saturating_sub(lines); 84 // Disable auto-scroll when scrolling up from bottom 85 let near_bottom_threshold = 3; 86 if self.scroll_offset + near_bottom_threshold < self.max_scroll() { 87 self.auto_scroll = false; 88 } 89 } 90 91 pub fn scroll_down(&mut self, lines: usize) { 92 let max_scroll = self.max_scroll(); 93 self.scroll_offset = (self.scroll_offset + lines).min(max_scroll); 94 // Re-enable auto-scroll when near bottom (within 3 lines) 95 let near_bottom_threshold = 3; 96 if self.scroll_offset + near_bottom_threshold >= max_scroll { 97 self.auto_scroll = true; 98 } 99 } 100 101 pub fn max_scroll(&self) -> usize { 102 // Guard against zero viewport height 103 if self.viewport_height == 0 { 104 return 0; 105 } 106 self.content_height.saturating_sub(self.viewport_height) 107 } 108 109 pub fn scroll_to_bottom(&mut self) { 110 self.scroll_offset = self.max_scroll(); 111 self.auto_scroll = true; 112 } 113} 114 115impl Default for ExecutionState { 116 fn default() -> Self { 117 Self::new() 118 } 119} 120 121pub fn draw_execution( 122 frame: &mut Frame, 123 area: Rect, 124 state: &mut ExecutionState, 125 spinner_char: char, 126) { 127 let chunks = Layout::default() 128 .direction(Direction::Horizontal) 129 .constraints([Constraint::Percentage(35), Constraint::Percentage(65)]) 130 .split(area); 131 132 draw_task_pane(frame, chunks[0], state, spinner_char); 133 draw_output_pane(frame, chunks[1], state); 134} 135 136fn draw_task_pane(frame: &mut Frame, area: Rect, state: &ExecutionState, spinner_char: char) { 137 let chunks = Layout::default() 138 .direction(Direction::Vertical) 139 .constraints([Constraint::Length(8), Constraint::Min(0)]) 140 .split(area); 141 142 let (completed, total) = state.task_progress(); 143 let title = if state.running { 144 format!(" Current Task {}/{} {} ", completed, total, spinner_char) 145 } else { 146 format!(" Current Task {}/{} ", completed, total) 147 }; 148 let current_block = Block::default().borders(Borders::ALL).title(title); 149 150 let current_content = if let Some(task) = &state.current_task { 151 let mut lines = vec![ 152 Line::from(Span::styled( 153 format!("{}", task.title), 154 Style::default().add_modifier(Modifier::BOLD), 155 )), 156 Line::from(""), 157 Line::from(Span::styled( 158 "Acceptance Criteria:", 159 Style::default().fg(Color::Cyan), 160 )), 161 ]; 162 for criterion in &task.acceptance_criteria { 163 lines.push(Line::from(format!("{}", criterion))); 164 } 165 lines 166 } else { 167 vec![Line::from("No task running")] 168 }; 169 170 let current = Paragraph::new(current_content) 171 .block(current_block) 172 .wrap(Wrap { trim: false }); 173 frame.render_widget(current, chunks[0]); 174 175 let tasks_block = Block::default().borders(Borders::ALL).title(" Tasks "); 176 177 let items: Vec<ListItem> = state 178 .tasks 179 .iter() 180 .map(|task| { 181 let (icon, style) = match task.status { 182 TaskStatus::Complete => ("", Style::default().fg(Color::Green)), 183 TaskStatus::InProgress => ("", Style::default().fg(Color::Yellow)), 184 TaskStatus::Pending => ("", Style::default().fg(Color::DarkGray)), 185 TaskStatus::Blocked => ("", Style::default().fg(Color::Red)), 186 }; 187 ListItem::new(format!(" {} {}", icon, task.title)).style(style) 188 }) 189 .collect(); 190 191 let list = List::new(items).block(tasks_block); 192 frame.render_widget(list, chunks[1]); 193} 194 195fn draw_output_pane(frame: &mut Frame, area: Rect, state: &mut ExecutionState) { 196 let block = Block::default().borders(Borders::ALL).title(" Output "); 197 198 let inner = block.inner(area); 199 frame.render_widget(block, area); 200 201 let mut lines: Vec<Line> = Vec::new(); 202 203 for item in &state.output { 204 match item { 205 OutputItem::Message { role, content } => { 206 let style = if role == "Assistant" { 207 Style::default() 208 .fg(Color::Yellow) 209 .add_modifier(Modifier::BOLD) 210 } else { 211 Style::default() 212 .fg(Color::Cyan) 213 .add_modifier(Modifier::BOLD) 214 }; 215 lines.push(Line::from(Span::styled(role.clone(), style))); 216 for line in content.lines() { 217 lines.push(Line::from(format!(" {}", line))); 218 } 219 lines.push(Line::from("")); 220 } 221 OutputItem::ToolCall(tc) => { 222 lines.push(Line::from(vec![ 223 Span::raw("┌─ "), 224 Span::styled( 225 tc.name.clone(), 226 Style::default() 227 .fg(Color::Magenta) 228 .add_modifier(Modifier::BOLD), 229 ), 230 Span::raw(""), 231 ])); 232 if !tc.collapsed { 233 for line in tc.output.lines().take(5) { 234 lines.push(Line::from(format!("{}", line))); 235 } 236 } 237 lines.push(Line::from("└────────────────────")); 238 lines.push(Line::from("")); 239 } 240 } 241 } 242 243 if lines.is_empty() { 244 lines.push(Line::from(" Waiting for execution...")); 245 } 246 247 // Calculate actual wrapped content height 248 let viewport_height = inner.height as usize; 249 let viewport_width = inner.width as usize; 250 251 // Calculate wrapped line count 252 let mut content_height = 0; 253 for line in &lines { 254 let line_width = line.width(); 255 if line_width == 0 { 256 content_height += 1; 257 } else { 258 // Account for wrapping 259 content_height += (line_width + viewport_width - 1) / viewport_width.max(1); 260 } 261 } 262 263 // Update state and handle pending scroll 264 state.content_height = content_height; 265 state.viewport_height = viewport_height; 266 state.last_area_width = inner.width; 267 268 if state.scroll_to_bottom_pending { 269 state.scroll_offset = content_height.saturating_sub(viewport_height); 270 state.scroll_to_bottom_pending = false; 271 } 272 273 // Clamp scroll 274 state.clamp_scroll(content_height, viewport_height); 275 276 let scroll_y = state.scroll_offset.min(u16::MAX as usize) as u16; 277 278 let paragraph = Paragraph::new(lines) 279 .wrap(Wrap { trim: false }) 280 .scroll((scroll_y, 0)); 281 282 frame.render_widget(paragraph, inner); 283 284 // Show scroll indicator if content is scrollable 285 if content_height > viewport_height { 286 let max_scroll = content_height.saturating_sub(viewport_height); 287 let scroll_pct = if max_scroll > 0 { 288 (state.scroll_offset * 100) / max_scroll 289 } else { 290 100 291 }; 292 293 // Add [auto] indicator if auto-scroll is enabled 294 let indicator = if state.auto_scroll { 295 format!(" {}% [auto] ", scroll_pct) 296 } else { 297 format!(" {}% ", scroll_pct) 298 }; 299 300 let indicator_width = indicator.len() as u16; 301 let indicator_area = Rect::new( 302 inner.x + inner.width.saturating_sub(indicator_width), 303 inner.y + inner.height.saturating_sub(1), 304 indicator_width, 305 1, 306 ); 307 308 let indicator_widget = 309 Paragraph::new(indicator).style(Style::default().bg(Color::DarkGray).fg(Color::White)); 310 311 frame.render_widget(indicator_widget, indicator_area); 312 } 313}