1mod autocomplete;
18mod chat;
19mod diff;
20mod header;
21mod help;
22mod input;
23mod layout;
24mod markdown;
25mod message;
26mod tables;
27pub mod theme;
28mod todo;
29mod tool_call;
30
31use crate::app::App;
32use ratatui::Frame;
33use ratatui::layout::{Alignment, Rect};
34use ratatui::style::{Color, Style};
35use ratatui::text::{Line, Span};
36use ratatui::widgets::Paragraph;
37use unicode_width::UnicodeWidthStr;
38
39pub fn render(frame: &mut Frame, app: &mut App) {
40 let _t = app.perf.as_ref().map(|p| p.start("ui::render"));
41 let frame_area = frame.area();
42 app.cached_frame_area = frame_area;
43 crate::perf::mark_with("ui::frame_width", "cols", usize::from(frame_area.width));
44 crate::perf::mark_with("ui::frame_height", "rows", usize::from(frame_area.height));
45
46 let todo_height = {
47 let _t = app.perf.as_ref().map(|p| p.start("ui::todo_height"));
48 todo::compute_height(app)
49 };
50 let help_height = {
51 let _t = app.perf.as_ref().map(|p| p.start("ui::help_height"));
52 help::compute_height(app, frame_area.width)
53 };
54 let input_visual_lines = {
55 let _t = app.perf.as_ref().map(|p| p.start("ui::input_visual_lines"));
56 input::visual_line_count(app, frame_area.width)
57 };
58 let areas = {
59 let _t = app.perf.as_ref().map(|p| p.start("ui::layout"));
60 layout::compute(frame_area, input_visual_lines, app.show_header, todo_height, help_height)
61 };
62
63 if areas.header.height > 0 {
65 let _t = app.perf.as_ref().map(|p| p.start("ui::header"));
66 render_separator(frame, areas.header_top_sep);
67 header::render(frame, areas.header, app);
68 render_separator(frame, areas.header_bot_sep);
69 }
70
71 {
73 let _t = app.perf.as_ref().map(|p| p.start("ui::chat"));
74 chat::render(frame, areas.body, app);
75 }
76
77 render_separator(frame, areas.input_sep);
79
80 if areas.todo.height > 0 {
82 let _t = app.perf.as_ref().map(|p| p.start("ui::todo"));
83 todo::render(frame, areas.todo, app);
84 }
85
86 {
88 let _t = app.perf.as_ref().map(|p| p.start("ui::input"));
89 input::render(frame, areas.input, app);
90 }
91
92 if autocomplete::is_active(app) {
94 let _t = app.perf.as_ref().map(|p| p.start("ui::autocomplete"));
95 autocomplete::render(frame, areas.input, app);
96 }
97
98 render_separator(frame, areas.input_bottom_sep);
100
101 if areas.help.height > 0 {
103 let _t = app.perf.as_ref().map(|p| p.start("ui::help"));
104 help::render(frame, areas.help, app);
105 }
106
107 if let Some(footer_area) = areas.footer {
109 let _t = app.perf.as_ref().map(|p| p.start("ui::footer"));
110 render_footer(frame, footer_area, app);
111 }
112
113 let fps_y = if areas.header.height > 0 { areas.header.y } else { frame_area.y };
114 render_perf_fps_overlay(frame, frame_area, fps_y, app);
115}
116
117const FOOTER_PAD: u16 = 2;
118
119fn render_footer(frame: &mut Frame, area: Rect, app: &mut App) {
120 let padded = Rect {
121 x: area.x + FOOTER_PAD,
122 y: area.y,
123 width: area.width.saturating_sub(FOOTER_PAD * 2),
124 height: area.height,
125 };
126
127 if app.cached_footer_line.is_none() {
128 let line = if let Some(ref mode) = app.mode {
129 let color = mode_color(&mode.current_mode_id);
130 Line::from(vec![
131 Span::styled("[", Style::default().fg(color)),
132 Span::styled(mode.current_mode_name.clone(), Style::default().fg(color)),
133 Span::styled("]", Style::default().fg(color)),
134 Span::raw(" "),
135 Span::styled("?", Style::default().fg(Color::White)),
136 Span::styled(" : Shortcuts + Commands", Style::default().fg(theme::DIM)),
137 ])
138 } else {
139 Line::from(vec![
140 Span::styled("?", Style::default().fg(Color::White)),
141 Span::styled(" : Shortcuts + Commands", Style::default().fg(theme::DIM)),
142 ])
143 };
144 app.cached_footer_line = Some(line);
145 }
146
147 if let Some(line) = &app.cached_footer_line {
148 frame.render_widget(Paragraph::new(line.clone()), padded);
149 render_footer_update_hint(frame, padded, line, app.update_check_hint.as_deref());
150 }
151}
152
153fn render_footer_update_hint(
154 frame: &mut Frame,
155 area: Rect,
156 left_line: &Line<'_>,
157 hint: Option<&str>,
158) {
159 let Some(hint) = hint else {
160 return;
161 };
162 if hint.trim().is_empty() || area.width == 0 {
163 return;
164 }
165
166 let left_width = line_display_width(left_line);
167 let hint_width = UnicodeWidthStr::width(hint);
168 if hint_width == 0 {
169 return;
170 }
171
172 if !footer_can_render_update_hint(usize::from(area.width), left_width, hint_width) {
174 return;
175 }
176
177 let line = Line::from(Span::styled(hint.to_owned(), Style::default().fg(theme::RUST_ORANGE)));
178 frame.render_widget(Paragraph::new(line).alignment(Alignment::Right), area);
179}
180
181fn line_display_width(line: &Line<'_>) -> usize {
182 line.spans.iter().map(|s| UnicodeWidthStr::width(s.content.as_ref())).sum()
183}
184
185fn footer_can_render_update_hint(area_width: usize, left_width: usize, hint_width: usize) -> bool {
186 let min_total = left_width.saturating_add(3).saturating_add(hint_width);
187 area_width > min_total
188}
189
190fn mode_color(mode_id: &str) -> Color {
192 match mode_id {
193 "default" => theme::DIM,
194 "plan" => Color::Blue,
195 "acceptEdits" => Color::Yellow,
196 "bypassPermissions" | "dontAsk" => Color::Red,
197 _ => Color::Magenta,
198 }
199}
200
201fn render_separator(frame: &mut Frame, area: Rect) {
202 if area.height == 0 {
203 return;
204 }
205 let sep_str = theme::SEPARATOR_CHAR.repeat(area.width as usize);
206 let line = Line::from(Span::styled(sep_str, Style::default().fg(theme::DIM)));
207 frame.render_widget(Paragraph::new(line), area);
208}
209
210#[cfg(feature = "perf")]
211fn render_perf_fps_overlay(frame: &mut Frame, frame_area: Rect, y: u16, app: &App) {
212 if app.perf.is_none() || frame_area.height == 0 || y >= frame_area.y + frame_area.height {
213 return;
214 }
215 let Some(fps) = app.frame_fps() else {
216 return;
217 };
218
219 let color = if fps >= 55.0 {
220 Color::Green
221 } else if fps >= 45.0 {
222 Color::Yellow
223 } else {
224 Color::Red
225 };
226 let text = format!("[{fps:>5.1} FPS]");
227 let width = u16::try_from(text.len()).unwrap_or(frame_area.width).min(frame_area.width);
228 let x = frame_area.x + frame_area.width.saturating_sub(width);
229 let area = Rect { x, y, width, height: 1 };
230 let line = Line::from(Span::styled(
231 text,
232 Style::default().fg(color).add_modifier(ratatui::style::Modifier::BOLD),
233 ));
234 frame.render_widget(Paragraph::new(line), area);
235}
236
237#[cfg(not(feature = "perf"))]
238fn render_perf_fps_overlay(_frame: &mut Frame, _frame_area: Rect, _y: u16, _app: &App) {}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn footer_hint_requires_gap_after_left_content() {
246 assert!(footer_can_render_update_hint(40, 20, 10));
247 assert!(!footer_can_render_update_hint(33, 20, 10));
248 }
249}