claude_code_rust/ui/
mod.rs1mod 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::Rect;
34use ratatui::style::{Color, Style};
35use ratatui::text::{Line, Span};
36use ratatui::widgets::Paragraph;
37
38pub fn render(frame: &mut Frame, app: &mut App) {
39 let _t = app.perf.as_ref().map(|p| p.start("ui::render"));
40 let frame_area = frame.area();
41 app.cached_frame_area = frame_area;
42 crate::perf::mark_with("ui::frame_width", "cols", usize::from(frame_area.width));
43 crate::perf::mark_with("ui::frame_height", "rows", usize::from(frame_area.height));
44
45 let todo_height = {
46 let _t = app.perf.as_ref().map(|p| p.start("ui::todo_height"));
47 todo::compute_height(app)
48 };
49 let help_height = {
50 let _t = app.perf.as_ref().map(|p| p.start("ui::help_height"));
51 help::compute_height(app, frame_area.width)
52 };
53 let input_visual_lines = {
54 let _t = app.perf.as_ref().map(|p| p.start("ui::input_visual_lines"));
55 input::visual_line_count(app, frame_area.width)
56 };
57 let areas = {
58 let _t = app.perf.as_ref().map(|p| p.start("ui::layout"));
59 layout::compute(frame_area, input_visual_lines, app.show_header, todo_height, help_height)
60 };
61
62 if areas.header.height > 0 {
64 let _t = app.perf.as_ref().map(|p| p.start("ui::header"));
65 render_separator(frame, areas.header_top_sep);
66 header::render(frame, areas.header, app);
67 render_separator(frame, areas.header_bot_sep);
68 }
69
70 {
72 let _t = app.perf.as_ref().map(|p| p.start("ui::chat"));
73 chat::render(frame, areas.body, app);
74 }
75
76 render_separator(frame, areas.input_sep);
78
79 if areas.todo.height > 0 {
81 let _t = app.perf.as_ref().map(|p| p.start("ui::todo"));
82 todo::render(frame, areas.todo, app);
83 }
84
85 {
87 let _t = app.perf.as_ref().map(|p| p.start("ui::input"));
88 input::render(frame, areas.input, app);
89 }
90
91 if autocomplete::is_active(app) {
93 let _t = app.perf.as_ref().map(|p| p.start("ui::autocomplete"));
94 autocomplete::render(frame, areas.input, app);
95 }
96
97 render_separator(frame, areas.input_bottom_sep);
99
100 if areas.help.height > 0 {
102 let _t = app.perf.as_ref().map(|p| p.start("ui::help"));
103 help::render(frame, areas.help, app);
104 }
105
106 if let Some(footer_area) = areas.footer {
108 let _t = app.perf.as_ref().map(|p| p.start("ui::footer"));
109 render_footer(frame, footer_area, app);
110 }
111
112 let fps_y = if areas.header.height > 0 { areas.header.y } else { frame_area.y };
113 render_perf_fps_overlay(frame, frame_area, fps_y, app);
114}
115
116const FOOTER_PAD: u16 = 2;
117
118fn render_footer(frame: &mut Frame, area: Rect, app: &mut App) {
119 let padded = Rect {
120 x: area.x + FOOTER_PAD,
121 y: area.y,
122 width: area.width.saturating_sub(FOOTER_PAD * 2),
123 height: area.height,
124 };
125
126 if app.cached_footer_line.is_none() {
127 let line = if let Some(ref mode) = app.mode {
128 let color = mode_color(&mode.current_mode_id);
129 Line::from(vec![
130 Span::styled("[", Style::default().fg(color)),
131 Span::styled(mode.current_mode_name.clone(), Style::default().fg(color)),
132 Span::styled("]", Style::default().fg(color)),
133 Span::raw(" "),
134 Span::styled("?", Style::default().fg(Color::White)),
135 Span::styled(" : Shortcuts + Commands", Style::default().fg(theme::DIM)),
136 ])
137 } else {
138 Line::from(vec![
139 Span::styled("?", Style::default().fg(Color::White)),
140 Span::styled(" : Shortcuts + Commands", Style::default().fg(theme::DIM)),
141 ])
142 };
143 app.cached_footer_line = Some(line);
144 }
145
146 if let Some(line) = &app.cached_footer_line {
147 frame.render_widget(Paragraph::new(line.clone()), padded);
148 }
149}
150
151fn mode_color(mode_id: &str) -> Color {
153 match mode_id {
154 "default" => theme::DIM,
155 "plan" => Color::Blue,
156 "acceptEdits" => Color::Yellow,
157 "bypassPermissions" | "dontAsk" => Color::Red,
158 _ => Color::Magenta,
159 }
160}
161
162fn render_separator(frame: &mut Frame, area: Rect) {
163 if area.height == 0 {
164 return;
165 }
166 let sep_str = theme::SEPARATOR_CHAR.repeat(area.width as usize);
167 let line = Line::from(Span::styled(sep_str, Style::default().fg(theme::DIM)));
168 frame.render_widget(Paragraph::new(line), area);
169}
170
171#[cfg(feature = "perf")]
172fn render_perf_fps_overlay(frame: &mut Frame, frame_area: Rect, y: u16, app: &App) {
173 if app.perf.is_none() || frame_area.height == 0 || y >= frame_area.y + frame_area.height {
174 return;
175 }
176 let Some(fps) = app.frame_fps() else {
177 return;
178 };
179
180 let color = if fps >= 55.0 {
181 Color::Green
182 } else if fps >= 45.0 {
183 Color::Yellow
184 } else {
185 Color::Red
186 };
187 let text = format!("[{fps:>5.1} FPS]");
188 let width = u16::try_from(text.len()).unwrap_or(frame_area.width).min(frame_area.width);
189 let x = frame_area.x + frame_area.width.saturating_sub(width);
190 let area = Rect { x, y, width, height: 1 };
191 let line = Line::from(Span::styled(
192 text,
193 Style::default().fg(color).add_modifier(ratatui::style::Modifier::BOLD),
194 ));
195 frame.render_widget(Paragraph::new(line), area);
196}
197
198#[cfg(not(feature = "perf"))]
199fn render_perf_fps_overlay(_frame: &mut Frame, _frame_area: Rect, _y: u16, _app: &App) {}