Skip to main content

claude_code_rust/ui/
mod.rs

1// Claude Code Rust - A native Rust terminal interface for Claude Code
2// Copyright (C) 2025  Simon Peter Rothgang
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU Affero General Public License as
6// published by the Free Software Foundation, either version 3 of the
7// License, or (at your option) any later version.
8//
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU Affero General Public License for more details.
13//
14// You should have received a copy of the GNU Affero General Public License
15// along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17mod 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    // Header bar (toggleable via Ctrl+H)
64    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    // Body: chat (includes welcome text when no messages yet)
72    {
73        let _t = app.perf.as_ref().map(|p| p.start("ui::chat"));
74        chat::render(frame, areas.body, app);
75    }
76
77    // Input separator (above)
78    render_separator(frame, areas.input_sep);
79
80    // Todo panel (below input top separator, above input)
81    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    // Input
87    {
88        let _t = app.perf.as_ref().map(|p| p.start("ui::input"));
89        input::render(frame, areas.input, app);
90    }
91
92    // Autocomplete dropdown (floating overlay above input)
93    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    // Input separator (below input)
99    render_separator(frame, areas.input_bottom_sep);
100
101    // Help overlay (below input separator)
102    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    // Footer: mode/help on the left, optional update hint on the right.
108    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    // Keep a small gap and skip rendering if the footer is too narrow.
173    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
190/// Returns a color for the given mode ID.
191fn 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}