use super::types::App;
use std::path::PathBuf;
impl App {
pub fn render<T: ratatui::backend::Backend>(
&mut self,
terminal: &mut ratatui::Terminal<T>,
) -> std::io::Result<()> {
if self.ui.needs_full_redraw {
terminal.clear()?;
self.ui.needs_full_redraw = false;
}
terminal.draw(|frame| crate::tui::render::render(frame, self))?;
Ok(())
}
pub fn discover_models(dirs: &[PathBuf]) -> Vec<crate::models::DiscoveredModel> {
let mut models = Vec::new();
for dir in dirs {
crate::backend::hub::walk_dir_recursive(dir, 0, 10, &mut |entry| {
let path = entry.path();
if path.is_file() && path.extension().map(|e| e == "gguf").unwrap_or(false)
&& let Some(name) = path.file_name().and_then(|n| n.to_str()) {
let name = name.to_string();
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
let display_name = path
.strip_prefix(dir)
.ok()
.and_then(|p| p.to_str())
.unwrap_or(&name)
.to_string();
models.push(crate::models::DiscoveredModel {
path,
name,
file_size: size,
display_name,
});
}
});
}
models.sort_by(|a, b| a.name.cmp(&b.name));
models
}
pub fn reset_to_defaults(&mut self) {
let defaults = crate::models::ModelSettings::default();
self.settings = defaults;
self.model_settings_cache = self.settings.clone();
self.loading.model_total_layers = 0;
self.loading.model_hidden_size = 0;
self.loading.model_n_ctx_train = 0;
self.loading.model_n_head = 0;
self.loading.model_n_kv_head = 0;
self.loading.vram_estimate = 0;
self.settings.spec_type = String::new();
self.settings.draft_tokens = 0;
self.settings_state.settings_render_cache = None;
self.add_log(
"Reset LLM Settings to defaults",
crate::config::LogLevel::Info,
);
self.ui.needs_redraw = true;
}
pub fn selected_model(&self) -> Option<&crate::models::DiscoveredModel> {
self.selected_model_idx.and_then(|i| self.models.get(i))
}
pub fn selected_model_settings(&self) -> crate::models::ModelSettings {
let model_name = self.selected_model().map(|m| m.name.as_str());
self.config.resolve_settings(model_name, None)
}
pub fn on_model_selection_change(&mut self) {
self.search.readme_cache = None;
if let Some(idx) = self.selected_model_idx {
let model = self.models[idx].clone();
self.model_settings_cache = self.selected_model_settings();
self.settings = self.model_settings_cache.clone();
self.update_model_metadata();
self.update_vram_estimate();
if self.is_model_loaded(&model.display_name) {
self.loading.loading_progress = 1.0;
if !self
.loading
.loading_phases
.contains(&super::types::LoadingPhase::Complete)
{
self.loading
.loading_phases
.insert(super::types::LoadingPhase::Complete);
}
} else if matches!(
self.model_states.get(&model.display_name),
Some(crate::models::ModelState::Loading)
| Some(crate::models::ModelState::Benchmarking)
) {
} else {
self.loading.loading_progress = 0.0;
self.loading.loading_phases.clear();
self.loading.last_active_phase = None;
self.loading.load_progress = Default::default();
}
} else {
let default_params = self.config.default.clone();
self.model_settings_cache = default_params.into();
self.loading.model_total_layers = 0;
self.loading.model_hidden_size = 0;
self.loading.model_n_ctx_train = 0;
self.settings.spec_type = String::new();
self.settings.draft_tokens = 0;
self.loading.vram_estimate = 0;
self.loading.loading_progress = 0.0;
self.loading.loading_phases.clear();
self.loading.last_active_phase = None;
}
self.ui.needs_redraw = true;
}
pub fn search_results_len(&self) -> usize {
if let super::types::ModelsMode::Search { results, .. } = &self.models_mode {
results.len()
} else {
0
}
}
pub fn get_filtered_model_indices(&self) -> Vec<usize> {
self.models
.iter()
.enumerate()
.filter(|(_, m)| {
self.search.local_filter.is_empty()
|| m.display_name
.to_lowercase()
.contains(&self.search.local_filter.to_lowercase())
})
.map(|(i, _)| i)
.collect()
}
}
fn normalize_separators(s: &str) -> String {
s.replace('_', "-")
}
pub fn model_is_downloaded(models: &[crate::models::DiscoveredModel], model_id: &str) -> bool {
let repo_name = model_id
.rsplit('/')
.next()
.unwrap_or(model_id)
.to_lowercase();
let repo_normalized = normalize_separators(&repo_name);
let repo_parts: Vec<&str> = repo_normalized.split('-').collect();
models.iter().any(|m| {
let mut local = m.name.to_lowercase();
if let Some(stripped) = local.strip_suffix(".gguf") {
local = stripped.to_string();
}
let local_normalized = normalize_separators(&local);
if local_normalized == repo_normalized {
return true;
}
for i in 1..=repo_parts.len() {
let prefix = repo_parts[..i].join("-");
if prefix.len() >= 8 && local_normalized.starts_with(&format!("{}-", prefix)) {
return true;
}
}
false
})
}
#[allow(dead_code)]
pub fn file_is_downloaded(models: &[crate::models::DiscoveredModel], filename: &str) -> bool {
let target = filename.to_lowercase();
models.iter().any(|m| {
let local = m.name.to_lowercase();
local == target
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::DiscoveredModel;
fn make_discovered(name: &str) -> DiscoveredModel {
DiscoveredModel {
path: PathBuf::from(format!("/models/{}", name)),
name: name.to_string(),
file_size: 0,
display_name: name.to_string(),
}
}
#[test]
fn exact_match() {
let models = vec![make_discovered("Qwen2.5-7B-Instruct.gguf")];
assert!(model_is_downloaded(&models, "Qwen/Qwen2.5-7B-Instruct"));
}
#[test]
fn hyphen_separator_match() {
let models = vec![make_discovered("Qwen2.5-7B-Instruct-Q4_K_M.gguf")];
assert!(model_is_downloaded(&models, "Qwen/Qwen2.5-7B-Instruct"));
}
#[test]
fn underscore_separator_match() {
let models = vec![make_discovered("Qwen2.5_7B_Instruct.gguf")];
assert!(model_is_downloaded(&models, "Qwen/Qwen2.5-7B-Instruct"));
}
#[test]
fn underscore_separator_match_with_quant() {
let models = vec![make_discovered("Qwen2.5_7B_Instruct-Q4_K_M.gguf")];
assert!(model_is_downloaded(&models, "Qwen/Qwen2.5-7B-Instruct"));
}
#[test]
fn case_insensitive_match() {
let models = vec![make_discovered("qwen2.5-7b-instruct-q4_k_m.gguf")];
assert!(model_is_downloaded(&models, "Qwen/Qwen2.5-7B-Instruct"));
}
#[test]
fn no_match_different_model() {
let models = vec![make_discovered("Llama-3.1-8B-Instruct-Q4_K_M.gguf")];
assert!(!model_is_downloaded(&models, "Qwen/Qwen2.5-7B-Instruct"));
}
#[test]
fn no_match_partial_prefix_false_positive() {
let models = vec![make_discovered("Qwen2.5-Mistral-Q4_K_M.gguf")];
assert!(!model_is_downloaded(&models, "Qwen/Qwen2.5-7B-Instruct"));
}
#[test]
fn no_match_empty_models() {
let models: Vec<DiscoveredModel> = vec![];
assert!(!model_is_downloaded(&models, "Qwen/Qwen2.5-7B-Instruct"));
}
#[test]
fn strip_gguf_extension() {
let models = vec![make_discovered("Qwen2.5-7B-Instruct-Q4_K_M.GGUF")];
assert!(model_is_downloaded(&models, "Qwen/Qwen2.5-7B-Instruct"));
}
#[test]
fn no_gguf_extension() {
let models = vec![make_discovered("Qwen2.5-7B-Instruct-Q4_K_M")];
assert!(model_is_downloaded(&models, "Qwen/Qwen2.5-7B-Instruct"));
}
#[test]
fn multiple_models() {
let models = vec![
make_discovered("Llama-3.1-8B-Instruct-Q4_K_M.gguf"),
make_discovered("Qwen2.5-7B-Instruct-Q4_K_M.gguf"),
make_discovered("Mistral-7B-v0.3-Q8_0.gguf"),
];
assert!(model_is_downloaded(&models, "Qwen/Qwen2.5-7B-Instruct"));
assert!(model_is_downloaded(
&models,
"meta-llama/Llama-3.1-8B-Instruct"
));
assert!(model_is_downloaded(&models, "mistralai/Mistral-7B-v0.3"));
assert!(!model_is_downloaded(&models, "google/gemma-2-9b"));
}
#[test]
fn repo_name_with_extra_suffix() {
let models = vec![make_discovered("Qwen3.6-27B-Q3_K_S.gguf")];
assert!(model_is_downloaded(&models, "unsloth/Qwen3.6-27B-MTP-GGUF"));
}
#[test]
fn repo_name_with_extra_suffix_different_size() {
let models = vec![make_discovered("Qwen3.6-7B-Q3_K_S.gguf")];
assert!(!model_is_downloaded(
&models,
"unsloth/Qwen3.6-27B-MTP-GGUF"
));
}
#[test]
fn file_exact_match() {
let models = vec![make_discovered("Qwen3.6-27B-Q3_K_S.gguf")];
assert!(file_is_downloaded(&models, "Qwen3.6-27B-Q3_K_S.gguf"));
}
#[test]
fn file_exact_match_case_insensitive() {
let models = vec![make_discovered("Qwen3.6-27B-Q3_K_S.gguf")];
assert!(file_is_downloaded(&models, "qwen3.6-27b-q3_k_s.gguf"));
}
#[test]
fn file_no_match_different_quant() {
let models = vec![make_discovered("Qwen3.6-27B-Q3_K_S.gguf")];
assert!(!file_is_downloaded(&models, "Qwen3.6-27B-Q4_K_M.gguf"));
}
#[test]
fn file_no_match_different_model() {
let models = vec![make_discovered("Qwen3.6-27B-Q3_K_S.gguf")];
assert!(!file_is_downloaded(
&models,
"Llama-3.1-8B-Instruct-Q4_K_M.gguf"
));
}
#[test]
fn file_empty_models() {
let models: Vec<DiscoveredModel> = vec![];
assert!(!file_is_downloaded(&models, "Qwen3.6-27B-Q3_K_S.gguf"));
}
}