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