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::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    // Header bar (toggleable via Ctrl+H)
63    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    // Body: chat (includes welcome text when no messages yet)
71    {
72        let _t = app.perf.as_ref().map(|p| p.start("ui::chat"));
73        chat::render(frame, areas.body, app);
74    }
75
76    // Input separator (above)
77    render_separator(frame, areas.input_sep);
78
79    // Todo panel (below input top separator, above input)
80    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    // Input
86    {
87        let _t = app.perf.as_ref().map(|p| p.start("ui::input"));
88        input::render(frame, areas.input, app);
89    }
90
91    // Autocomplete dropdown (floating overlay above input)
92    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    // Input separator (below input)
98    render_separator(frame, areas.input_bottom_sep);
99
100    // Help overlay (below input separator)
101    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    // Footer: mode pill left, command hints right
107    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
151/// Returns a color for the given mode ID.
152fn 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) {}