1use ratatui::prelude::*;
22use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
23
24use crate::tools::todo::TodoStatus;
25use crate::tui::app::App;
26use crate::tui::ui::{command_menu, input, modal, spinner, status, transcript};
27
28fn todo_panel_height(app: &App) -> u16 {
30 if app.current_todos.is_empty() {
31 0
32 } else {
33 (app.current_todos.len().min(6) as u16) + 2
35 }
36}
37
38pub fn render(frame: &mut Frame, app: &App) {
39 let input_total = input::total_height(app);
40 let todo_height = todo_panel_height(app);
41 let plan_banner_height: u16 = if app.plan_awaiting_approval || app.plan_mode_request_pending {
46 1
47 } else {
48 0
49 };
50 let chunks = Layout::default()
51 .direction(Direction::Vertical)
52 .constraints([
53 Constraint::Min(3), Constraint::Length(todo_height), Constraint::Length(1), Constraint::Length(plan_banner_height), Constraint::Length(input_total), ])
59 .split(frame.area());
60
61 let inflight = &app.blocks[app.last_printed_idx..];
65 let mut lines = transcript::render_blocks(inflight, &app.usage, app.theme);
66 if app.turn.running {
67 let elapsed = app
68 .turn
69 .started_at
70 .map(|t| t.elapsed().as_secs_f64())
71 .unwrap_or(0.0);
72 lines.push(Line::raw(""));
73 lines.push(Line::from(vec![Span::styled(
74 spinner::format_line(app.spinner_frame, app.turn.spinner_verb, elapsed),
75 Style::default().fg(Color::Yellow),
76 )]));
77 }
78
79 let messages_area = chunks[0];
80 let todo_area = chunks[1];
81 let inner_width = messages_area.width;
84 let visible_rows = messages_area.height;
85
86 let total_rows: u16 = if inner_width == 0 {
95 lines.len() as u16
96 } else {
97 let w = inner_width as usize;
98 let sum: usize = lines
99 .iter()
100 .map(|l| {
101 let lw = l.width();
102 if lw == 0 {
103 1
104 } else {
105 lw.div_ceil(w)
106 }
107 })
108 .sum();
109 sum.try_into().unwrap_or(u16::MAX)
110 };
111 let max_scroll = total_rows.saturating_sub(visible_rows);
112 let effective_scroll = max_scroll.saturating_sub(app.scroll_offset.min(max_scroll));
113
114 let messages_widget = Paragraph::new(lines)
115 .wrap(Wrap { trim: false })
116 .scroll((effective_scroll, 0));
117 frame.render_widget(messages_widget, messages_area);
118
119 if !app.current_todos.is_empty() {
121 render_todo_panel(frame, todo_area, app);
122 }
123
124 status::render(frame, chunks[2], app);
126
127 if app.plan_awaiting_approval {
130 render_plan_approval_banner(frame, chunks[3], app);
131 } else if app.plan_mode_request_pending {
132 render_plan_mode_request_banner(frame, chunks[3]);
133 }
134
135 input::render(frame, chunks[4], app);
139
140 command_menu::render(frame, chunks[4], app);
143
144 command_menu::render_atfile(frame, chunks[4], app);
146
147 command_menu::render_history_search(frame, chunks[4], app);
149
150 command_menu::render_permission_modal(frame, app);
152
153 if !app.modals.is_empty() {
155 modal::render(frame, app);
156 }
157}
158
159fn render_todo_panel(frame: &mut Frame, area: Rect, app: &App) {
164 let completed = app
165 .current_todos
166 .iter()
167 .filter(|t| t.status == TodoStatus::Completed)
168 .count();
169 let total = app.current_todos.len();
170 let title = format!(" Tasks ({completed}/{total} done) ");
171
172 let items: Vec<Line> = app
173 .current_todos
174 .iter()
175 .take(6)
176 .map(|item| {
177 let (icon, style) = match item.status {
178 TodoStatus::Completed => ("✓", Style::default().fg(Color::Green)),
179 TodoStatus::InProgress => ("◉", Style::default().fg(Color::Yellow)),
180 TodoStatus::Pending => ("○", Style::default().fg(Color::DarkGray)),
181 TodoStatus::Cancelled => ("✗", Style::default().fg(Color::DarkGray)),
182 };
183 let label = item
184 .active_form
185 .as_deref()
186 .filter(|_| item.status == TodoStatus::InProgress)
187 .unwrap_or(&item.content);
188 Line::from(vec![
189 Span::styled(format!(" {icon} "), style),
190 Span::styled(label.to_string(), style),
191 ])
192 })
193 .collect();
194
195 let widget = Paragraph::new(items)
196 .block(Block::default().borders(Borders::ALL).title(title))
197 .wrap(Wrap { trim: true });
198 frame.render_widget(widget, area);
199}
200
201fn render_plan_approval_banner(frame: &mut Frame, area: Rect, _app: &App) {
208 use ratatui::style::Modifier;
209 let line = Line::from(vec![
210 Span::styled(
211 " ⚡ Plan awaiting approval — ",
212 Style::default()
213 .fg(Color::Black)
214 .bg(Color::Yellow)
215 .add_modifier(Modifier::BOLD),
216 ),
217 Span::styled(
218 "[y/Enter]",
219 Style::default()
220 .fg(Color::Black)
221 .bg(Color::Green)
222 .add_modifier(Modifier::BOLD),
223 ),
224 Span::styled(
225 " Approve ",
226 Style::default().fg(Color::Black).bg(Color::Yellow),
227 ),
228 Span::styled(
229 "[n/Esc]",
230 Style::default()
231 .fg(Color::White)
232 .bg(Color::Red)
233 .add_modifier(Modifier::BOLD),
234 ),
235 Span::styled(
236 " Reject ",
237 Style::default().fg(Color::Black).bg(Color::Yellow),
238 ),
239 Span::styled(
240 "[e]",
241 Style::default()
242 .fg(Color::Black)
243 .bg(Color::Cyan)
244 .add_modifier(Modifier::BOLD),
245 ),
246 Span::styled(
247 " Edit ",
248 Style::default().fg(Color::Black).bg(Color::Yellow),
249 ),
250 ]);
251 let widget = Paragraph::new(line);
252 frame.render_widget(widget, area);
253}
254
255fn render_plan_mode_request_banner(frame: &mut Frame, area: Rect) {
262 use ratatui::style::Modifier;
263 let line = Line::from(vec![
264 Span::styled(
265 " ⓘ Plan mode request — ",
266 Style::default()
267 .fg(Color::Black)
268 .bg(Color::Blue)
269 .add_modifier(Modifier::BOLD),
270 ),
271 Span::styled(
272 "[y/Enter]",
273 Style::default()
274 .fg(Color::Black)
275 .bg(Color::Green)
276 .add_modifier(Modifier::BOLD),
277 ),
278 Span::styled(
279 " Allow ",
280 Style::default().fg(Color::Black).bg(Color::Blue),
281 ),
282 Span::styled(
283 "[n/Esc]",
284 Style::default()
285 .fg(Color::White)
286 .bg(Color::Red)
287 .add_modifier(Modifier::BOLD),
288 ),
289 Span::styled(
290 " Skip — execute directly ",
291 Style::default().fg(Color::White).bg(Color::Blue),
292 ),
293 ]);
294 let widget = Paragraph::new(line)
295 .style(Style::default().bg(Color::Blue))
296 .wrap(Wrap { trim: true });
297 frame.render_widget(widget, area);
298}