Skip to main content

llm_manager/tui/
render.rs

1use ratatui::{
2    Frame,
3    style::{Color, Modifier, Style},
4    text::{Line, Span},
5    widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
6};
7
8use crate::tui::app::{ActivePanel, App, ModelsMode};
9use crate::tui::panel;
10
11mod hints;
12mod overlays;
13mod status;
14
15fn render_scrollbar(f: &mut Frame, area: ratatui::layout::Rect, total_items: usize, scroll_offset: usize) {
16    let scrollbar_area = ratatui::layout::Rect {
17        x: area.right().saturating_sub(1),
18        y: area.top(),
19        width: 1,
20        height: area.height,
21    };
22    let mut scrollbar_state = ScrollbarState::new(total_items).position(scroll_offset);
23    f.render_stateful_widget(
24        Scrollbar::new(ScrollbarOrientation::VerticalRight)
25            .begin_symbol(Some("↑"))
26            .end_symbol(Some("↓")),
27        scrollbar_area,
28        &mut scrollbar_state,
29    );
30}
31
32pub fn render(f: &mut Frame, app: &mut App) {
33    if overlays::render_overlays(f, app) {
34        return;
35    }
36
37    let is_search = matches!(app.models_mode, ModelsMode::Search { .. });
38    let active_model_visible = app.is_panel_visible(4) && !is_search;
39    let log_visible = app.is_panel_visible(5);
40
41    let chunks = if app.log.log_expanded {
42        ratatui::layout::Layout::default()
43            .direction(ratatui::layout::Direction::Vertical)
44            .margin(0)
45            .constraints([
46                ratatui::layout::Constraint::Length(1),
47                ratatui::layout::Constraint::Fill(1),
48            ])
49            .split(f.area())
50    } else {
51        let active_model_constraint = if active_model_visible {
52            ratatui::layout::Constraint::Length(6)
53        } else {
54            ratatui::layout::Constraint::Length(0)
55        };
56
57        let bottom_constraint = if log_visible && !active_model_visible {
58            ratatui::layout::Constraint::Fill(1)
59        } else {
60            let mut h = 0;
61            if log_visible {
62                h += 3;
63            }
64            if app.download.downloading {
65                h += 7;
66            }
67
68            if h > 0 {
69                ratatui::layout::Constraint::Min(h)
70            } else {
71                ratatui::layout::Constraint::Length(0)
72            }
73        };
74
75        ratatui::layout::Layout::default()
76            .direction(ratatui::layout::Direction::Vertical)
77            .margin(0)
78            .constraints([
79                ratatui::layout::Constraint::Length(1),
80                ratatui::layout::Constraint::Fill(3),
81                active_model_constraint,
82                bottom_constraint,
83            ])
84            .split(f.area())
85    };
86
87    let status = status::render_status_bar(app, chunks[0]);
88    f.render_widget(Paragraph::new(status), chunks[0]);
89
90    if app.log.log_expanded {
91        let log_area = chunks[1];
92        panel::log::render(f, log_area, app);
93        return;
94    }
95
96    let top_chunks = if !app.is_panel_visible(1)
97        && !app.is_panel_visible(3)
98        && !matches!(
99            app.ui.active_panel,
100            ActivePanel::Profiles | ActivePanel::SystemPromptPresets | ActivePanel::SearchReadme
101        ) {
102        ratatui::layout::Layout::default()
103            .direction(ratatui::layout::Direction::Horizontal)
104            .constraints([
105                ratatui::layout::Constraint::Percentage(100),
106                ratatui::layout::Constraint::Length(0),
107            ])
108            .split(chunks[1])
109    } else {
110        let left_pct = app.ui.left_pct.clamp(20, 80);
111        ratatui::layout::Layout::default()
112            .direction(ratatui::layout::Direction::Horizontal)
113            .constraints([
114                ratatui::layout::Constraint::Fill(left_pct),
115                ratatui::layout::Constraint::Fill(100 - left_pct),
116            ])
117            .split(chunks[1])
118    };
119
120    let info_visible = app.is_panel_visible(2);
121    let (left_chunks, info_lines) = if info_visible {
122        let lines = panel::tabbed::get_info_lines(app, top_chunks[0].width);
123        let info_height = (lines.len() as u16 + 2).max(3);
124        let chunks = ratatui::layout::Layout::default()
125            .direction(ratatui::layout::Direction::Vertical)
126            .constraints([
127                ratatui::layout::Constraint::Min(5),
128                ratatui::layout::Constraint::Length(info_height),
129            ])
130            .split(top_chunks[0]);
131        (chunks, Some(lines))
132    } else {
133        let chunks = ratatui::layout::Layout::default()
134            .direction(ratatui::layout::Direction::Vertical)
135            .constraints([ratatui::layout::Constraint::Fill(1)])
136            .split(top_chunks[0]);
137        (chunks, None)
138    };
139
140    panel::models::render(f, left_chunks[0], app);
141
142    if let Some(lines) = info_lines {
143        panel::tabbed::render_info_with_lines(f, left_chunks[1], lines);
144    }
145
146    match app.ui.active_panel {
147        ActivePanel::Profiles => {
148            let all_profiles = app.config.merged_profiles();
149            let (profile_lines, count) = panel::profiles::render_all(
150                &all_profiles,
151                app.settings_state.settings_selected_idx,
152                &app.settings,
153            );
154            if app.settings_state.settings_selected_idx >= count {
155                app.settings_state.settings_selected_idx = count.saturating_sub(1);
156            }
157
158            let area = top_chunks[1];
159            let available_height = area.height.saturating_sub(2);
160
161            let max_offset = profile_lines
162                .len()
163                .saturating_sub(available_height as usize) as u16;
164            if app.picker.profiles_scroll_offset > max_offset.into() {
165                app.picker.profiles_scroll_offset = max_offset.into();
166            }
167
168            let start_idx = app.picker.profiles_scroll_offset;
169            let visible_lines: Vec<Line> = profile_lines
170                .iter()
171                .skip(start_idx)
172                .take(available_height as usize)
173                .cloned()
174                .collect();
175
176            let block = Block::default()
177                .title(" Profiles ")
178                .borders(Borders::ALL)
179                .border_style(Style::default().fg(Color::Yellow));
180            let paragraph = Paragraph::new(visible_lines).block(block);
181            f.render_widget(paragraph, area);
182
183            if profile_lines.len() > available_height as usize {
184                render_scrollbar(f, area, profile_lines.len(), app.picker.profiles_scroll_offset);
185            }
186        }
187        ActivePanel::SystemPromptPresets => {
188            let presets = app.config.merged_presets();
189            let preset_lines = panel::system_prompt_presets::render_all(
190                &presets,
191                app.settings_state.settings_selected_idx,
192                app.edit.editing_preset.is_some(),
193                &app.settings_state.settings_edit_buffer,
194                app.edit.edit_cursor_pos,
195            );
196
197            let area = top_chunks[1];
198            let available_height = area.height.saturating_sub(2);
199
200            let max_offset = preset_lines.len().saturating_sub(available_height as usize) as u16;
201            if app.picker.system_prompt_presets_scroll_offset > max_offset.into() {
202                app.picker.system_prompt_presets_scroll_offset = max_offset.into();
203            }
204
205            let start_idx = app.picker.system_prompt_presets_scroll_offset;
206            let visible_lines: Vec<Line> = preset_lines
207                .iter()
208                .skip(start_idx)
209                .take(available_height as usize)
210                .cloned()
211                .collect();
212
213            let block = Block::default()
214                .title(" System Prompt Presets ")
215                .borders(Borders::ALL)
216                .border_style(Style::default().fg(Color::Yellow));
217            let paragraph = Paragraph::new(visible_lines).block(block);
218            f.render_widget(paragraph, area);
219
220            if preset_lines.len() > available_height as usize {
221                render_scrollbar(f, area, preset_lines.len(), app.picker.system_prompt_presets_scroll_offset);
222            }
223        }
224        _ => {
225            let show_readme = match &app.models_mode {
226                ModelsMode::Search { show_readme, .. } => *show_readme,
227                ModelsMode::Files { .. } => true,
228                _ => false,
229            };
230            if show_readme {
231                panel::readme::render(f, top_chunks[1], app);
232            } else {
233                let server_visible = app.is_panel_visible(1);
234                let llm_visible = app.is_panel_visible(3);
235                if server_visible && llm_visible {
236                    panel::tabbed::render_settings_only(f, top_chunks[1], app);
237                } else if server_visible {
238                    panel::tabbed::render_server_only(f, top_chunks[1], app);
239                } else if llm_visible {
240                    panel::tabbed::render_llm_only(f, top_chunks[1], app);
241                }
242            }
243        }
244    }
245
246    if active_model_visible {
247        panel::active::render(f, chunks[2], app);
248    }
249
250    let bottom_area = chunks[3];
251    if log_visible && app.download.downloading {
252        let bottom_chunks = ratatui::layout::Layout::default()
253            .direction(ratatui::layout::Direction::Vertical)
254            .constraints([
255                ratatui::layout::Constraint::Fill(1),
256                ratatui::layout::Constraint::Length(7),
257            ])
258            .split(bottom_area);
259
260        let downloads_focused = app.ui.active_panel == ActivePanel::Downloads;
261
262        panel::log::render(f, bottom_chunks[0], app);
263
264        let total_speed: f64 = app
265            .download
266            .download_progress
267            .iter()
268            .map(|d| d.bytes_per_second)
269            .sum();
270        panel::models::render_download_panel(
271            f,
272            bottom_chunks[1],
273            &app.download.download_progress,
274            total_speed,
275            &mut app.download.download_scroll_state,
276            downloads_focused,
277        );
278    } else if log_visible {
279        panel::log::render(f, bottom_area, app);
280    } else if app.download.downloading {
281        let total_speed: f64 = app
282            .download
283            .download_progress
284            .iter()
285            .map(|d| d.bytes_per_second)
286            .sum();
287        let downloads_focused = app.ui.active_panel == ActivePanel::Downloads;
288        panel::models::render_download_panel(
289            f,
290            bottom_area,
291            &app.download.download_progress,
292            total_speed,
293            &mut app.download.download_scroll_state,
294            downloads_focused,
295        );
296    }
297}