use ratatui::{
Frame,
layout::Rect,
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use super::info;
use super::info::ModelInfoPair;
use super::settings;
use crate::tui::app::{ActivePanel, App};
use crate::tui::settings as settings_helper;
const SERVER_SETTINGS_HEIGHT: u16 = 8;
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: 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) =
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 visible_lines: Vec<Line<'static>> = settings_lines
.iter()
.skip(start_idx)
.take(available_height as usize)
.cloned()
.collect();
let border_color = if is_focused {
Color::Green
} else {
Color::Rgb(255, 165, 0)
};
let vram_text = crate::tui::format_size(app.loading.vram_estimate * 1024 * 1024);
let title_prefix = if app.settings_state.expert_mode {
" EXPERT - "
} else {
""
};
let title = format!("{} LLM Settings (F4) [Ctrl+F9] ", title_prefix);
let block = Block::default()
.title(Line::from(vec![
Span::raw(title),
Span::styled(
format!("(VRAM ~= {}) ", vram_text),
Style::default().fg(Color::Yellow),
),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
let paragraph = Paragraph::new(visible_lines).block(block);
f.render_widget(paragraph, llm_area);
if settings_height > available_height as usize {
crate::tui::render_vertical_scrollbar(
f,
llm_area,
settings_height,
app.settings_state.settings_scroll_offset,
0,
0,
);
}
}
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 {
Color::DarkGray
} else if is_focused {
Color::Green
} else {
Color::Rgb(255, 165, 0)
};
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 threads_val = format!("{}", app.settings.threads);
let threads_batch_val = format!("{}", app.settings.threads_batch);
let mode_val = format!("{}", app.server_mode);
let api_enabled = if app.settings.api_endpoint_enabled {
"True"
} else {
"False"
};
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,
"Threads",
&threads_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,
3,
"Threads Batch",
&threads_batch_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,
4,
"Mode",
&mode_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,
"API Endpoint",
api_enabled,
selected,
"",
false,
server_running,
);
let dashboard_val = format!(
"Dashboard ({})",
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,
6,
"Dashboard",
&dashboard_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,
7,
"RPC Workers",
&rpc_workers_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 {
" Server Settings (F2) [Ctrl+F8] (Disabled) "
} else {
" Server Settings (F2) [Ctrl+F8] "
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
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_focused = app.ui.active_panel == ActivePanel::LlmSettings;
let border_color = if is_focused {
Color::Green
} else {
Color::Rgb(255, 165, 0)
};
let vram_text = crate::tui::format_size(app.loading.vram_estimate * 1024 * 1024);
let title = " LLM Settings (F4) [4] ";
let block = Block::default()
.title(Line::from(vec![
Span::raw(title),
Span::styled(
format!("(VRAM ~= {}) ", vram_text),
Style::default().fg(Color::Yellow),
),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
let (all_lines, _count, settings_height, _selected_line_idx) = settings::render_all(app, area, false);
let available_height = area.height.saturating_sub(2);
let start_idx = app.settings_state.settings_scroll_offset;
let visible_lines: Vec<Line> = all_lines
.iter()
.skip(start_idx)
.take(available_height as usize)
.cloned()
.collect();
let paragraph = Paragraph::new(visible_lines).block(block);
f.render_widget(paragraph, area);
if settings_height > available_height as usize {
crate::tui::render_vertical_scrollbar(
f,
area,
settings_height,
app.settings_state.settings_scroll_offset,
0,
0,
);
}
}
fn empty_info() -> Vec<Line<'static>> {
vec![Line::from(Span::styled(
"Select a model to view info",
Style::default().fg(Color::DarkGray),
))]
}
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(Color::Yellow)),
Span::styled(filename.clone(), Style::default().fg(Color::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,
}
});
state.max_offset = max_offset;
state.visible = true;
let mut lines = render_model_info_lines(&pairs, width, Some(state));
if cached_meta.is_none() {
lines.push(Line::from(vec![Span::styled(
"GGUF metadata not available — check log for errors",
Style::default().fg(Color::DarkGray),
)]));
}
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(Color::DarkGray),
),
Span::styled(r.model_id.clone(), Style::default().fg(Color::DarkGray)),
]);
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(Color::DarkGray),
),
Span::styled(r.model_id.clone(), Style::default().fg(Color::DarkGray)),
]);
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(" Model Info (F3)")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Blue));
let paragraph = Paragraph::new(lines).block(block);
f.render_widget(paragraph, area);
}
fn render_model_info_lines(pairs: &[ModelInfoPair], width: u16, state: Option<&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!("{}: ", 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(Color::Yellow)),
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!("{}: ", left.label);
let right_label = format!("{}: ", right.label);
lines.push(Line::from(vec![
Span::styled(
format!("{:<12}", left_label),
Style::default().fg(Color::Yellow),
),
Span::styled(
format!("{:<15}", left.value),
Style::default().fg(left.value_style),
),
Span::raw(" "),
Span::styled(
format!("{:<12}", right_label),
Style::default().fg(Color::Yellow),
),
Span::styled(right.value.clone(), Style::default().fg(right.value_style)),
]));
} else {
let label = format!("{}: ", left.label);
lines.push(Line::from(vec![
Span::styled(label, Style::default().fg(Color::Yellow)),
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(Color::Yellow)),
Span::styled(r.model_id.clone(), Style::default().fg(Color::White)),
])];
if let Some(s) = size_str.clone() {
lines.push(Line::from(vec![
Span::styled("Size: ", Style::default().fg(Color::Yellow)),
Span::styled(s, Style::default().fg(Color::White)),
]));
}
lines.push(Line::from(vec![
Span::styled("Params: ", Style::default().fg(Color::Yellow)),
Span::styled(params_str.clone(), Style::default().fg(Color::White)),
Span::raw(" | "),
Span::styled("Type: ", Style::default().fg(Color::Yellow)),
Span::styled(cap_str.clone(), Style::default().fg(Color::White)),
]));
lines.push(Line::from(vec![
Span::styled("Pipeline: ", Style::default().fg(Color::Yellow)),
Span::styled(pipeline_str.to_string(), Style::default().fg(Color::White)),
Span::raw(" | "),
Span::styled("Downloads: ", Style::default().fg(Color::Yellow)),
Span::styled(
format!("{}", r.downloads),
Style::default().fg(Color::White),
),
]));
lines.push(Line::from(vec![
Span::styled("Likes: ", Style::default().fg(Color::Yellow)),
Span::styled(format!("{}", r.likes), Style::default().fg(Color::White)),
Span::raw(" | "),
Span::styled("Trending: ", Style::default().fg(Color::Yellow)),
Span::styled(
format!("{}", r.trending_score),
Style::default().fg(Color::White),
),
]));
let license: String = r.license.as_deref().unwrap_or("—").to_string();
lines.push(Line::from(vec![
Span::styled("License: ", Style::default().fg(Color::Yellow)),
Span::styled(license, Style::default().fg(Color::White)),
]));
if let Some(created) = &r.created_at {
lines.push(Line::from(vec![
Span::styled("Created: ", Style::default().fg(Color::Yellow)),
Span::styled(created.clone(), Style::default().fg(Color::White)),
]));
}
lines.push(Line::from(vec![
Span::styled("Tags: ", Style::default().fg(Color::Yellow)),
Span::styled(tag_str, Style::default().fg(Color::White)),
]));
lines
}