use ratatui::{
Frame,
layout::Rect,
prelude::Stylize,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph},
};
use super::info;
use super::info::ModelInfoPair;
use super::settings;
use crate::tui::app::{ActivePanel, App};
use crate::tui::colors::*;
use crate::tui::settings as settings_helper;
fn render_unsaved_watermark(f: &mut Frame, area: Rect, app: &App) {
if !app.is_settings_dirty() {
return;
}
let watermark = crate::t!("hints.unsaved_watermark");
let lines: Vec<Line> = watermark
.chars()
.map(|c| {
Line::from(vec![Span::styled(
format!("{} ", c),
Style::default().fg(RED).add_modifier(Modifier::BOLD),
)])
})
.collect();
let paragraph = Paragraph::new(lines);
f.render_widget(paragraph, area);
}
pub fn render_settings_only(f: &mut Frame, area: Rect, app: &mut App) {
if area.height < 2 || area.width < 10 {
return;
}
let is_focused = app.ui.active_panel == crate::tui::app::ActivePanel::LlmSettings;
let server_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: app.ui.server_settings_height.min(area.height),
};
let llm_area = Rect {
x: area.x,
y: area.y + server_area.height,
width: area.width,
height: area.height.saturating_sub(server_area.height),
};
render_server_settings(f, server_area, app);
let (settings_lines, _count, settings_height, _selected_line_idx, help_line) =
settings::render_all(app, llm_area, false);
let available_height = llm_area.height.saturating_sub(2);
let start_idx = app.settings_state.settings_scroll_offset;
let _border_color = if is_focused { LIGHT_GREEN } else { DIM_GRAY };
let vram_text = crate::tui::format_size(app.loading.vram_estimate * 1024 * 1024);
let title = if app.settings_state.expert_mode {
crate::t!("panel.title.llm_expert").to_string()
} else {
crate::t!("panel.title.llm_active").to_string()
};
let is_llm_focused = app.ui.active_panel == crate::tui::app::ActivePanel::LlmSettings;
let title_color = if is_llm_focused { GREEN } else { ACCENT };
let border_color = if app.is_settings_dirty() {
RED
} else if is_llm_focused {
LIGHT_GREEN
} else {
LIGHT_GRAY
};
let border_type = if is_llm_focused {
BorderType::Double
} else {
BorderType::Rounded
};
let block = Block::default()
.title(Line::from(vec![
Span::raw(title),
Span::styled(
format!("(VRAM ~= {}) ", vram_text),
Style::default().fg(ACCENT),
),
]))
.title_style(Style::default().fg(title_color))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.border_type(border_type);
let inner = block.inner(llm_area);
f.render_widget(Paragraph::new(vec![]).block(block), llm_area);
render_unsaved_watermark(f, llm_area, app);
if let Some(ref help_text) = help_line {
let help_char_len = help_text.chars().count();
let help_text_lines = if inner.width > 0 {
((help_char_len as f64) / (inner.width as f64)).ceil() as u16
} else {
1
};
let help_area_height = help_text_lines.clamp(1, 20) + 1;
let visible_lines: Vec<Line<'static>> = settings_lines
.iter()
.skip(start_idx)
.take((available_height as usize).saturating_sub(help_area_height as usize))
.cloned()
.collect();
let help_area = Rect {
x: inner.x,
y: inner.y + inner.height.saturating_sub(help_area_height),
width: inner.width,
height: help_area_height,
};
let help_block = Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(MID_GRAY))
.border_type(BorderType::Rounded)
.bg(BLACK);
let help_inner = help_block.inner(help_area);
f.render_widget(help_block.clone(), help_area);
f.render_widget(
Paragraph::new(help_text.as_str()).wrap(ratatui::widgets::Wrap { trim: true }),
help_inner,
);
let settings_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: inner.height.saturating_sub(help_area_height),
};
f.render_widget(Paragraph::new(visible_lines), settings_area);
if settings_height > available_height.saturating_sub(help_area_height) as usize {
crate::tui::render_vertical_scrollbar(
f,
llm_area,
settings_height,
app.settings_state.settings_scroll_offset,
1,
2,
);
}
} else {
let visible_lines: Vec<Line<'static>> = settings_lines
.iter()
.skip(start_idx)
.take(available_height as usize)
.cloned()
.collect();
let settings_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: inner.height,
};
f.render_widget(Paragraph::new(visible_lines), settings_area);
if settings_height > available_height as usize {
crate::tui::render_vertical_scrollbar(
f,
llm_area,
settings_height,
app.settings_state.settings_scroll_offset,
1,
2,
);
}
}
}
fn render_server_settings(f: &mut Frame, area: Rect, app: &mut App) {
let server_running = app.server.server_handle.is_some()
|| matches!(app.models_mode, crate::tui::app::ModelsMode::BenchTune);
if area.height < 2 || area.width < 10 {
return;
}
let is_focused = app.ui.active_panel == crate::tui::app::ActivePanel::ServerSettings;
let _border_color = if server_running {
DIM_GRAY
} else if is_focused {
LIGHT_GREEN
} else {
DIM_GRAY
};
let selected = app.settings_state.server_settings_selected_idx;
let host_val = crate::models::format_host(&app.settings.host);
let backend_name = format!(
"{} (v{})",
app.settings.backend,
app.settings.get_active_backend_version_display()
);
let api_enabled = if app.settings.api_endpoint_enabled {
"Enabled"
} else {
"Disabled"
};
let rpc_workers_count = app.config.rpc_workers.iter().filter(|w| w.selected).count();
let rpc_workers_val = if rpc_workers_count > 0 {
format!("{} active", rpc_workers_count)
} else {
"None".to_string()
};
let mut lines = Vec::new();
let mut count = 0;
let mut selected_line_idx = 0;
let mut selected_content_line = 0;
settings_helper::add_setting(
&mut lines,
&mut count,
&app.settings,
&app.settings,
&mut selected_line_idx,
&mut selected_content_line,
0,
"Host",
&host_val,
selected,
"",
false,
server_running,
);
settings_helper::add_setting(
&mut lines,
&mut count,
&app.settings,
&app.settings,
&mut selected_line_idx,
&mut selected_content_line,
1,
"Backend",
&backend_name,
selected,
"",
false,
server_running,
);
settings_helper::add_setting(
&mut lines,
&mut count,
&app.settings,
&app.settings,
&mut selected_line_idx,
&mut selected_content_line,
2,
"API Endpoint",
api_enabled,
selected,
"",
false,
server_running,
);
let dashboard_enabled = if app.config.default.ws_server_enabled {
"Enabled"
} else {
"Disabled"
};
settings_helper::add_setting(
&mut lines,
&mut count,
&app.settings,
&app.settings,
&mut selected_line_idx,
&mut selected_content_line,
3,
"Dashboard",
dashboard_enabled,
selected,
"",
false,
server_running,
);
let llama_options_val = format!(
"Port {} | {}T / {}TB | {} | {}",
app.settings.port,
app.settings.threads,
app.settings.threads_batch,
app.server_mode,
app.config.default.log_level,
);
settings_helper::add_setting(
&mut lines,
&mut count,
&app.settings,
&app.settings,
&mut selected_line_idx,
&mut selected_content_line,
4,
crate::t!("panel.llama_server.section"),
&llama_options_val,
selected,
"",
false,
server_running,
);
settings_helper::add_setting(
&mut lines,
&mut count,
&app.settings,
&app.settings,
&mut selected_line_idx,
&mut selected_content_line,
5,
"RPC Workers",
&rpc_workers_val,
selected,
"",
false,
server_running,
);
let web_search_display = if app.config.default.web_search_enabled {
"Enabled (searxng)".to_string()
} else {
"Disabled (searxng)".to_string()
};
settings_helper::add_setting(
&mut lines,
&mut count,
&app.settings,
&app.settings,
&mut selected_line_idx,
&mut selected_content_line,
6,
"Web Search",
&web_search_display,
selected,
"",
false,
server_running,
);
let language_val = app.config.language.to_uppercase();
settings_helper::add_setting(
&mut lines,
&mut count,
&app.settings,
&app.settings,
&mut selected_line_idx,
&mut selected_content_line,
7,
"Language",
&language_val,
selected,
"",
false,
server_running,
);
let total_settings = lines.len();
let available_height = area.height.saturating_sub(2);
if selected_content_line < app.settings_state.server_settings_scroll_offset {
app.settings_state.server_settings_scroll_offset = selected_content_line;
} else if available_height > 0
&& (selected_content_line - app.settings_state.server_settings_scroll_offset)
>= (available_height as usize)
{
app.settings_state.server_settings_scroll_offset = (selected_content_line)
.saturating_sub(available_height as usize)
.saturating_add(1);
}
let max_offset = total_settings.saturating_sub(available_height as usize);
if app.settings_state.server_settings_scroll_offset > max_offset {
app.settings_state.server_settings_scroll_offset = max_offset;
}
let start_idx = app.settings_state.server_settings_scroll_offset;
let visible_lines: Vec<Line> = lines
.into_iter()
.skip(start_idx)
.take(available_height as usize)
.collect();
let title = if server_running {
crate::t!("panel.title.server_disabled")
} else {
crate::t!("panel.title.server_active")
};
let is_server_focused = app.ui.active_panel == crate::tui::app::ActivePanel::ServerSettings;
let title_color = if is_server_focused { GREEN } else { ACCENT };
let (border_type, border_color) = if is_server_focused {
(BorderType::Double, LIGHT_GREEN)
} else {
(BorderType::Rounded, LIGHT_GRAY)
};
let block = Block::default()
.title(title)
.title_style(Style::default().fg(title_color))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.border_type(border_type);
let paragraph = Paragraph::new(visible_lines).block(block);
f.render_widget(paragraph, area);
if total_settings > available_height as usize {
crate::tui::render_vertical_scrollbar(
f,
area,
total_settings,
app.settings_state.server_settings_scroll_offset,
1,
2,
);
}
}
pub fn render_server_only(f: &mut Frame, area: Rect, app: &mut App) {
render_server_settings(f, area, app);
}
pub fn render_llm_only(f: &mut Frame, area: Rect, app: &mut App) {
let is_llm_focused = app.ui.active_panel == ActivePanel::LlmSettings;
let border_color = if app.is_settings_dirty() {
RED
} else if is_llm_focused {
LIGHT_GREEN
} else {
LIGHT_GRAY
};
let border_type = if is_llm_focused {
BorderType::Double
} else {
BorderType::Rounded
};
let vram_text = crate::tui::format_size(app.loading.vram_estimate * 1024 * 1024);
let title = crate::t!("panel.title.llm_active");
let block = Block::default()
.title(Line::from(vec![
Span::raw(title),
Span::styled(
format!("(VRAM ~= {}) ", vram_text),
Style::default().fg(ACCENT),
),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.border_type(border_type);
let (all_lines, _count, settings_height, _selected_line_idx, help_line) =
settings::render_all(app, area, false);
let available_height = area.height.saturating_sub(2);
let start_idx = app.settings_state.settings_scroll_offset;
let inner = block.inner(area);
f.render_widget(Paragraph::new(vec![]).block(block), area);
render_unsaved_watermark(f, area, app);
if let Some(ref help_text) = help_line {
let max_width = inner.width;
let help_char_len = help_text.chars().count();
let help_text_lines = if max_width > 0 {
((help_char_len as f64) / (max_width as f64)).ceil() as u16
} else {
1
};
let help_area_height = help_text_lines.clamp(1, 20) + 1;
let visible_lines: Vec<Line> = all_lines
.iter()
.skip(start_idx)
.take((available_height as usize).saturating_sub(help_area_height as usize))
.cloned()
.collect();
let help_area = Rect {
x: inner.x,
y: inner.y + inner.height.saturating_sub(help_area_height),
width: inner.width,
height: help_area_height,
};
let help_block = Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(MID_GRAY))
.border_type(BorderType::Rounded)
.bg(BLACK);
f.render_widget(help_block.clone(), help_area);
f.render_widget(
Paragraph::new(help_text.as_str()).wrap(ratatui::widgets::Wrap { trim: true }),
help_block.inner(help_area),
);
let settings_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: inner.height.saturating_sub(help_area_height),
};
f.render_widget(Paragraph::new(visible_lines), settings_area);
if settings_height > available_height.saturating_sub(help_area_height) as usize {
crate::tui::render_vertical_scrollbar(
f,
area,
settings_height,
app.settings_state.settings_scroll_offset,
1,
2,
);
}
} else {
let visible_lines: Vec<Line> = all_lines
.iter()
.skip(start_idx)
.take(available_height as usize)
.cloned()
.collect();
let settings_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: inner.height,
};
f.render_widget(Paragraph::new(visible_lines), settings_area);
if settings_height > available_height as usize {
crate::tui::render_vertical_scrollbar(
f,
area,
settings_height,
app.settings_state.settings_scroll_offset,
1,
2,
);
}
}
}
fn empty_info() -> Vec<Line<'static>> {
vec![Line::from(Span::styled(
"Select a model to view info",
Style::default().fg(DIM_GRAY),
))]
}
pub fn get_info_lines(app: &mut App, width: u16) -> Vec<Line<'static>> {
let mut info_lines: Vec<Line<'static>> = match &app.models_mode {
crate::tui::app::ModelsMode::Search { results, .. } => {
if let Some(idx) = app.search.search_results_idx {
if idx < results.len() {
let r = &results[idx];
render_search_result_info(r, None)
} else {
empty_info()
}
} else {
empty_info()
}
}
crate::tui::app::ModelsMode::Files {
selected_result,
files,
selected_idx,
..
} => {
let mut lines = if let Some(r) = selected_result {
let file_info = selected_idx
.and_then(|idx| files.get(idx).map(|(f, s, _): &(_, _, _)| (f.clone(), *s)));
render_search_result_info(r, file_info)
} else {
Vec::new()
};
if let Some(idx) = selected_idx
&& let Some((filename, _, _url)) = files.get(*idx)
{
lines.push(Line::from(vec![
Span::styled("File: ", Style::default().fg(ACCENT)),
Span::styled(filename.clone(), Style::default().fg(WHITE)),
]));
}
lines
}
_ => {
match app.selected_model() {
Some(model) => {
let key = model.path.to_string_lossy().to_string();
let cached_meta = app.search.gguf_metadata_cache.get(&key);
let pairs = info::render_model_lines(model, cached_meta);
let value_width = width.saturating_sub(7);
let path_str = model.path.to_string_lossy().to_string();
let max_offset = path_str
.chars()
.count()
.saturating_sub(value_width as usize);
let state = app.ui.text_scrolls.entry(key.clone()).or_insert_with(|| {
crate::tui::app::TextScrollState {
offset: 0,
last_tick: std::time::Instant::now(),
direction: 1,
hold_count: 0,
max_offset,
visible: false,
cached_output: None,
cached_width: 0,
cached_offset: 0,
}
});
state.max_offset = max_offset;
state.visible = true;
let mut lines = render_model_info_lines(&pairs, width, state);
if cached_meta.is_none() {
lines.push(Line::from(vec![Span::styled(
"GGUF metadata not available — check log for errors",
Style::default().fg(DIM_GRAY),
)]));
}
lines
}
None => empty_info(),
}
}
};
if let crate::tui::app::ModelsMode::Search { results, .. } = &app.models_mode {
if let Some(idx) = app.search.search_results_idx
&& idx < results.len()
{
let r = &results[idx];
let link_line = Line::from(vec![
Span::styled(" https://huggingface.co/", Style::default().fg(CYAN)),
Span::styled(r.model_id.clone(), Style::default().fg(CYAN)),
]);
info_lines.push(link_line);
}
} else if let crate::tui::app::ModelsMode::Files {
selected_result, ..
} = &app.models_mode
&& let Some(r) = selected_result
{
let link_line = Line::from(vec![
Span::styled(" https://huggingface.co/", Style::default().fg(CYAN)),
Span::styled(r.model_id.clone(), Style::default().fg(CYAN)),
]);
info_lines.push(link_line);
}
info_lines
}
pub fn render_info_with_lines(f: &mut Frame, area: Rect, lines: Vec<Line<'static>>) {
let block = Block::default()
.title(crate::t!("panel.title.model_info_active"))
.title_style(Style::default().fg(ACCENT))
.borders(Borders::ALL)
.border_style(Style::default().fg(LIGHT_GRAY))
.border_type(BorderType::Rounded);
let paragraph = Paragraph::new(lines).block(block);
f.render_widget(paragraph, area);
}
fn render_model_info_lines(
pairs: &[ModelInfoPair],
width: u16,
state: &mut crate::tui::app::TextScrollState,
) -> Vec<Line<'static>> {
if pairs.is_empty() {
return empty_info();
}
let mut lines: Vec<Line<'static>> = Vec::new();
if let Some(first) = pairs.first() {
let label = format!("{}: ", crate::t!(first.label));
let value = first.value.clone();
let value_width = width.saturating_sub(label.len() as u16 + 1);
let value_display = crate::tui::panel::models::scroll_text(&value, value_width, state);
lines.push(Line::from(vec![
Span::styled(label, Style::default().fg(ACCENT)),
Span::styled(value_display, Style::default().fg(first.value_style)),
]));
}
let remaining: Vec<&ModelInfoPair> = pairs.iter().skip(1).collect();
for chunk in remaining.chunks(2) {
let left = chunk[0];
if let Some(right) = chunk.get(1) {
let left_label = format!("{}: ", crate::t!(left.label));
let right_label = format!("{}: ", crate::t!(right.label));
lines.push(Line::from(vec![
Span::styled(format!("{:<12}", left_label), Style::default().fg(ACCENT)),
Span::styled(
format!("{:<15}", left.value),
Style::default().fg(left.value_style),
),
Span::raw(" "),
Span::styled(format!("{:<12}", right_label), Style::default().fg(ACCENT)),
Span::styled(right.value.clone(), Style::default().fg(right.value_style)),
]));
} else {
let label = format!("{}: ", crate::t!(left.label));
lines.push(Line::from(vec![
Span::styled(label, Style::default().fg(ACCENT)),
Span::styled(left.value.clone(), Style::default().fg(left.value_style)),
]));
}
}
lines
}
fn extract_params_from_filename(filename: &str) -> String {
let stem = crate::models::strip_gguf(filename.rsplit('/').next().unwrap_or(filename));
let upper = stem.to_uppercase();
for i in (0..stem.len()).rev() {
let ch = upper.chars().nth(i).unwrap();
if ch.is_ascii_digit() || ch == '.' {
continue;
}
if ch == 'B' || ch == 'A' {
let start = if let Some(dash_pos) = stem[..=i].rfind('-') {
dash_pos + 1
} else {
0
};
let token = &stem[start..=i];
let token_upper = token.to_uppercase();
if token_upper.ends_with("A3B") {
return token.to_string();
}
if token_upper.ends_with('B') {
let digits_part = &token_upper[..token_upper.len() - 1];
if digits_part.chars().all(|c| c.is_ascii_digit() || c == '.')
&& !digits_part.is_empty()
{
return token.to_string();
}
}
break;
}
}
"N/A".to_string()
}
fn render_search_result_info(
r: &crate::models::SearchResult,
file_info: Option<(String, u64)>,
) -> Vec<Line<'static>> {
let size_str = file_info
.as_ref()
.map(|(_, size)| crate::tui::format_size(*size))
.or_else(|| r.size.map(crate::tui::format_size));
let params_str = if let Some((filename, _)) = &file_info {
extract_params_from_filename(filename)
} else {
r.parameters.as_deref().unwrap_or("N/A").to_string()
};
let cap_str: String = if r.capabilities.is_empty() {
"N/A".to_string()
} else {
r.capabilities
.iter()
.take(5)
.map(|c| c.as_str())
.collect::<Vec<_>>()
.join(", ")
};
let pipeline_str: String = r.pipeline_tag.as_deref().unwrap_or("N/A").to_string();
let tag_str: String = r
.tags
.iter()
.take(3)
.cloned()
.collect::<Vec<_>>()
.join(", ");
let mut lines = vec![Line::from(vec![
Span::styled("Model: ", Style::default().fg(ACCENT)),
Span::styled(r.model_id.clone(), Style::default().fg(WHITE)),
])];
if let Some(s) = size_str.clone() {
lines.push(Line::from(vec![
Span::styled("Size: ", Style::default().fg(ACCENT)),
Span::styled(s, Style::default().fg(WHITE)),
]));
}
lines.push(Line::from(vec![
Span::styled("Params: ", Style::default().fg(ACCENT)),
Span::styled(params_str.clone(), Style::default().fg(WHITE)),
Span::raw(" | "),
Span::styled("Type: ", Style::default().fg(ACCENT)),
Span::styled(cap_str.clone(), Style::default().fg(WHITE)),
]));
lines.push(Line::from(vec![
Span::styled("Pipeline: ", Style::default().fg(ACCENT)),
Span::styled(pipeline_str.to_string(), Style::default().fg(WHITE)),
Span::raw(" | "),
Span::styled("Downloads: ", Style::default().fg(ACCENT)),
Span::styled(format!("{}", r.downloads), Style::default().fg(WHITE)),
]));
lines.push(Line::from(vec![
Span::styled("Likes: ", Style::default().fg(ACCENT)),
Span::styled(format!("{}", r.likes), Style::default().fg(WHITE)),
Span::raw(" | "),
Span::styled("Trending: ", Style::default().fg(ACCENT)),
Span::styled(format!("{}", r.trending_score), Style::default().fg(WHITE)),
]));
let license: String = r.license.as_deref().unwrap_or("—").to_string();
lines.push(Line::from(vec![
Span::styled("License: ", Style::default().fg(ACCENT)),
Span::styled(license, Style::default().fg(WHITE)),
]));
if let Some(created) = &r.created_at {
lines.push(Line::from(vec![
Span::styled("Created: ", Style::default().fg(ACCENT)),
Span::styled(created.clone(), Style::default().fg(WHITE)),
]));
}
lines.push(Line::from(vec![
Span::styled("Tags: ", Style::default().fg(ACCENT)),
Span::styled(tag_str, Style::default().fg(WHITE)),
]));
lines
}