From 0b93f79732959fd8a342279865567000a0030447 Mon Sep 17 00:00:00 2001 From: mertsatilmaz Date: Sun, 12 Apr 2026 16:27:24 +0100 Subject: [PATCH 1/3] feat: persist filter state across sessions Saves all filter selections (fit, availability, TP, sort, installed-first, search query, and all multi-select popup filters) to ~/.config/llmfit/filters.json so they are restored on next launch. Follows the existing theme persistence pattern. Multi-select filters are stored by name rather than index position, so changes to the model database between runs won't corrupt saved state. Closes #428 --- llmfit-tui/src/filter_config.rs | 86 ++++++++++++++ llmfit-tui/src/main.rs | 1 + llmfit-tui/src/tui_app.rs | 194 +++++++++++++++++++++++++++++--- 3 files changed, 264 insertions(+), 17 deletions(-) create mode 100644 llmfit-tui/src/filter_config.rs diff --git a/llmfit-tui/src/filter_config.rs b/llmfit-tui/src/filter_config.rs new file mode 100644 index 00000000..8efbef55 --- /dev/null +++ b/llmfit-tui/src/filter_config.rs @@ -0,0 +1,86 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +/// Persisted filter state, saved to `~/.config/llmfit/filters.json`. +/// +/// Every field is optional so the file degrades gracefully when new filters are +/// added or the model database changes between runs. Multi-select filters are +/// stored as `name -> selected` maps so additions/removals in the model list +/// don't corrupt saved state. +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct FilterConfig { + pub fit_filter: Option, + pub availability_filter: Option, + pub tp_filter: Option, + pub sort_column: Option, + pub sort_ascending: Option, + pub installed_first: Option, + pub search_query: Option, + + // Multi-select popup filters: name → selected + pub providers: Option>, + pub use_cases: Option>, + pub capabilities: Option>, + pub quants: Option>, + pub run_modes: Option>, + pub params_buckets: Option>, + pub licenses: Option>, + pub runtimes: Option>, +} + +impl FilterConfig { + /// Path to the config file: `~/.config/llmfit/filters.json` + fn config_path() -> Option { + let home = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .ok()?; + Some( + PathBuf::from(home) + .join(".config") + .join("llmfit") + .join("filters.json"), + ) + } + + /// Load the saved filter config from disk, falling back to defaults. + pub fn load() -> Self { + Self::config_path() + .and_then(|path| fs::read_to_string(path).ok()) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } + + /// Save the current filter config to disk. + pub fn save(&self) { + if let Some(path) = Self::config_path() { + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string_pretty(self) { + let _ = fs::write(&path, json); + } + } + } + + /// Apply a saved name→selected map onto a positional `Vec`, + /// matching by the corresponding names vector. Entries not present + /// in the saved map keep their current (default) value. + pub fn apply_map(names: &[String], selected: &mut [bool], saved: &HashMap) { + for (i, name) in names.iter().enumerate() { + if let Some(&val) = saved.get(name) { + selected[i] = val; + } + } + } + + /// Build a name→selected map from parallel name and selected slices. + pub fn build_map(names: &[String], selected: &[bool]) -> HashMap { + names + .iter() + .zip(selected.iter()) + .map(|(name, &sel)| (name.clone(), sel)) + .collect() + } +} diff --git a/llmfit-tui/src/main.rs b/llmfit-tui/src/main.rs index 9523fd63..f3172ac5 100644 --- a/llmfit-tui/src/main.rs +++ b/llmfit-tui/src/main.rs @@ -1,4 +1,5 @@ mod display; +mod filter_config; mod serve_api; mod theme; mod tui_app; diff --git a/llmfit-tui/src/tui_app.rs b/llmfit-tui/src/tui_app.rs index 3942d3b3..d8a580e8 100644 --- a/llmfit-tui/src/tui_app.rs +++ b/llmfit-tui/src/tui_app.rs @@ -10,6 +10,7 @@ use llmfit_core::providers::{ use std::collections::{HashMap, HashSet}; use std::sync::mpsc; +use crate::filter_config::FilterConfig; use crate::theme::Theme; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -107,6 +108,17 @@ impl FitFilter { } } + pub fn from_label(s: &str) -> Self { + match s { + "Perfect" => FitFilter::Perfect, + "Good" => FitFilter::Good, + "Marginal" => FitFilter::Marginal, + "Too Tight" => FitFilter::TooTight, + "Runnable" => FitFilter::Runnable, + _ => FitFilter::All, + } + } + pub fn next(&self) -> Self { match self { FitFilter::All => FitFilter::Runnable, @@ -136,6 +148,14 @@ impl AvailabilityFilter { } } + pub fn from_label(s: &str) -> Self { + match s { + "GGUF Avail" => AvailabilityFilter::HasGguf, + "Installed" => AvailabilityFilter::Installed, + _ => AvailabilityFilter::All, + } + } + pub fn next(&self) -> Self { match self { AvailabilityFilter::All => AvailabilityFilter::HasGguf, @@ -163,6 +183,15 @@ impl TpFilter { } } + pub fn from_label(s: &str) -> Self { + match s { + "TP=2" => TpFilter::Tp2, + "TP=3" => TpFilter::Tp3, + "TP=4" => TpFilter::Tp4, + _ => TpFilter::All, + } + } + pub fn next(&self) -> Self { match self { TpFilter::All => TpFilter::Tp2, @@ -224,6 +253,19 @@ impl ActivePullProvider { } } +fn sort_column_from_label(s: &str) -> SortColumn { + match s { + "Score" => SortColumn::Score, + "tok/s" => SortColumn::Tps, + "Params" => SortColumn::Params, + "Mem%" => SortColumn::MemPct, + "Ctx" => SortColumn::Ctx, + "Date" => SortColumn::ReleaseDate, + "Use" => SortColumn::UseCase, + _ => SortColumn::Score, + } +} + pub struct App { pub should_quit: bool, pub input_mode: InputMode, @@ -435,7 +477,7 @@ impl App { .collect(); model_providers.sort(); - let selected_providers = vec![true; model_providers.len()]; + let mut selected_providers = vec![true; model_providers.len()]; let model_use_cases = [ UseCase::General, UseCase::Coding, @@ -447,10 +489,10 @@ impl App { .into_iter() .filter(|uc| all_fits.iter().any(|f| f.use_case == *uc)) .collect::>(); - let selected_use_cases = vec![true; model_use_cases.len()]; + let mut selected_use_cases = vec![true; model_use_cases.len()]; let model_capabilities = Capability::all().to_vec(); - let selected_capabilities = vec![true; model_capabilities.len()]; + let mut selected_capabilities = vec![true; model_capabilities.len()]; // Extract unique quantizations let mut model_quants: Vec = all_fits @@ -460,7 +502,7 @@ impl App { .into_iter() .collect(); model_quants.sort(); - let selected_quants = vec![true; model_quants.len()]; + let mut selected_quants = vec![true; model_quants.len()]; // Run modes let model_run_modes = vec![ @@ -469,7 +511,7 @@ impl App { "CPU+GPU".to_string(), "CPU".to_string(), ]; - let selected_run_modes = vec![true; model_run_modes.len()]; + let mut selected_run_modes = vec![true; model_run_modes.len()]; // Params buckets let params_buckets = vec![ @@ -480,7 +522,7 @@ impl App { "30-70B".to_string(), "70B+".to_string(), ]; - let selected_params_buckets = vec![true; params_buckets.len()]; + let mut selected_params_buckets = vec![true; params_buckets.len()]; // Extract unique licenses (including "Unknown" for models without one) let mut model_licenses: Vec = all_fits @@ -499,7 +541,7 @@ impl App { let unknown = model_licenses.remove(pos); model_licenses.push(unknown); } - let selected_licenses = vec![true; model_licenses.len()]; + let mut selected_licenses = vec![true; model_licenses.len()]; // Static runtime options — filter by compatibility, not assigned runtime let model_runtimes = vec![ @@ -507,7 +549,66 @@ impl App { "MLX".to_string(), "vLLM".to_string(), ]; - let selected_runtimes = vec![true; model_runtimes.len()]; + let mut selected_runtimes = vec![true; model_runtimes.len()]; + + // ── Restore persisted filters ──────────────────────────────── + let saved = FilterConfig::load(); + + let fit_filter = saved + .fit_filter + .as_deref() + .map(FitFilter::from_label) + .unwrap_or(FitFilter::All); + let availability_filter = saved + .availability_filter + .as_deref() + .map(AvailabilityFilter::from_label) + .unwrap_or(AvailabilityFilter::All); + let tp_filter = saved + .tp_filter + .as_deref() + .map(TpFilter::from_label) + .unwrap_or(TpFilter::All); + let sort_column = saved + .sort_column + .as_deref() + .map(sort_column_from_label) + .unwrap_or(SortColumn::Score); + let sort_ascending = saved.sort_ascending.unwrap_or(false); + let installed_first = saved.installed_first.unwrap_or(false); + let search_query = saved.search_query.clone().unwrap_or_default(); + let cursor_position = search_query.len(); + + if let Some(ref map) = saved.providers { + FilterConfig::apply_map(&model_providers, &mut selected_providers, map); + } + if let Some(ref map) = saved.use_cases { + let names: Vec = + model_use_cases.iter().map(|uc| uc.label().to_string()).collect(); + FilterConfig::apply_map(&names, &mut selected_use_cases, map); + } + if let Some(ref map) = saved.capabilities { + let names: Vec = model_capabilities + .iter() + .map(|c| c.label().to_string()) + .collect(); + FilterConfig::apply_map(&names, &mut selected_capabilities, map); + } + if let Some(ref map) = saved.quants { + FilterConfig::apply_map(&model_quants, &mut selected_quants, map); + } + if let Some(ref map) = saved.run_modes { + FilterConfig::apply_map(&model_run_modes, &mut selected_run_modes, map); + } + if let Some(ref map) = saved.params_buckets { + FilterConfig::apply_map(¶ms_buckets, &mut selected_params_buckets, map); + } + if let Some(ref map) = saved.licenses { + FilterConfig::apply_map(&model_licenses, &mut selected_licenses, map); + } + if let Some(ref map) = saved.runtimes { + FilterConfig::apply_map(&model_runtimes, &mut selected_runtimes, map); + } let filtered_count = all_fits.len(); @@ -516,8 +617,8 @@ impl App { let mut app = App { should_quit: false, input_mode: InputMode::Normal, - search_query: String::new(), - cursor_position: 0, + search_query, + cursor_position, specs, all_fits, filtered_fits: (0..filtered_count).collect(), @@ -527,12 +628,12 @@ impl App { selected_use_cases, capabilities: model_capabilities, selected_capabilities, - fit_filter: FitFilter::All, - availability_filter: AvailabilityFilter::All, - tp_filter: TpFilter::All, - installed_first: false, - sort_column: SortColumn::Score, - sort_ascending: false, + fit_filter, + availability_filter, + tp_filter, + installed_first, + sort_column, + sort_ascending, selected_row: 0, show_detail: false, show_compare: false, @@ -618,11 +719,69 @@ impl App { backend_hidden_count, }; - app.apply_filters(); + app.re_sort(); app.enqueue_capability_probes_for_visible(24); app } + /// Persist the current filter state to disk. + pub fn save_filters(&self) { + let use_case_names: Vec = + self.use_cases.iter().map(|uc| uc.label().to_string()).collect(); + let capability_names: Vec = self + .capabilities + .iter() + .map(|c| c.label().to_string()) + .collect(); + + let config = FilterConfig { + fit_filter: Some(self.fit_filter.label().to_string()), + availability_filter: Some(self.availability_filter.label().to_string()), + tp_filter: Some(self.tp_filter.label().to_string()), + sort_column: Some(self.sort_column.label().to_string()), + sort_ascending: Some(self.sort_ascending), + installed_first: Some(self.installed_first), + search_query: if self.search_query.is_empty() { + None + } else { + Some(self.search_query.clone()) + }, + providers: Some(FilterConfig::build_map( + &self.providers, + &self.selected_providers, + )), + use_cases: Some(FilterConfig::build_map( + &use_case_names, + &self.selected_use_cases, + )), + capabilities: Some(FilterConfig::build_map( + &capability_names, + &self.selected_capabilities, + )), + quants: Some(FilterConfig::build_map( + &self.quants, + &self.selected_quants, + )), + run_modes: Some(FilterConfig::build_map( + &self.run_modes, + &self.selected_run_modes, + )), + params_buckets: Some(FilterConfig::build_map( + &self.params_buckets, + &self.selected_params_buckets, + )), + licenses: Some(FilterConfig::build_map( + &self.licenses, + &self.selected_licenses, + )), + runtimes: Some(FilterConfig::build_map( + &self.runtimes, + &self.selected_runtimes, + )), + }; + config.save(); + } + pub fn apply_filters(&mut self) { let query = self.search_query.to_lowercase(); // Split query into space-separated terms for fuzzy matching @@ -834,6 +993,7 @@ impl App { self.selected_row = self.filtered_fits.len() - 1; } self.enqueue_capability_probes_for_visible(24); + self.save_filters(); } pub fn selected_fit(&self) -> Option<&ModelFit> { From cd32d19c1df527e639790bea04fa10b5ee70ddf6 Mon Sep 17 00:00:00 2001 From: mertsatilmaz Date: Sun, 12 Apr 2026 17:59:09 +0100 Subject: [PATCH 2/3] fix: save filters on quit and remove redundant re_sort on startup Address PR review feedback: - Move save_filters() from apply_filters() to quit handler to avoid disk I/O on every filter change and ensure all paths persist state - Replace re_sort() with apply_filters() on startup since all_fits is already sorted during construction - Fix rustfmt formatting --- llmfit-tui/src/tui_app.rs | 21 +++++++++++---------- llmfit-tui/src/tui_events.rs | 1 + 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/llmfit-tui/src/tui_app.rs b/llmfit-tui/src/tui_app.rs index d8a580e8..86d82483 100644 --- a/llmfit-tui/src/tui_app.rs +++ b/llmfit-tui/src/tui_app.rs @@ -583,8 +583,10 @@ impl App { FilterConfig::apply_map(&model_providers, &mut selected_providers, map); } if let Some(ref map) = saved.use_cases { - let names: Vec = - model_use_cases.iter().map(|uc| uc.label().to_string()).collect(); + let names: Vec = model_use_cases + .iter() + .map(|uc| uc.label().to_string()) + .collect(); FilterConfig::apply_map(&names, &mut selected_use_cases, map); } if let Some(ref map) = saved.capabilities { @@ -719,15 +721,18 @@ impl App { backend_hidden_count, }; - app.re_sort(); + app.apply_filters(); app.enqueue_capability_probes_for_visible(24); app } /// Persist the current filter state to disk. pub fn save_filters(&self) { - let use_case_names: Vec = - self.use_cases.iter().map(|uc| uc.label().to_string()).collect(); + let use_case_names: Vec = self + .use_cases + .iter() + .map(|uc| uc.label().to_string()) + .collect(); let capability_names: Vec = self .capabilities .iter() @@ -758,10 +763,7 @@ impl App { &capability_names, &self.selected_capabilities, )), - quants: Some(FilterConfig::build_map( - &self.quants, - &self.selected_quants, - )), + quants: Some(FilterConfig::build_map(&self.quants, &self.selected_quants)), run_modes: Some(FilterConfig::build_map( &self.run_modes, &self.selected_run_modes, @@ -993,7 +995,6 @@ impl App { self.selected_row = self.filtered_fits.len() - 1; } self.enqueue_capability_probes_for_visible(24); - self.save_filters(); } pub fn selected_fit(&self) -> Option<&ModelFit> { diff --git a/llmfit-tui/src/tui_events.rs b/llmfit-tui/src/tui_events.rs index 4c8ab26f..89b629bf 100644 --- a/llmfit-tui/src/tui_events.rs +++ b/llmfit-tui/src/tui_events.rs @@ -49,6 +49,7 @@ fn handle_normal_mode(app: &mut App, key: KeyEvent) { } else if app.show_compare { app.show_compare = false; } else { + app.save_filters(); app.should_quit = true; } } From 3dbf3dbce5f9f9039044799866646112c129dea9 Mon Sep 17 00:00:00 2001 From: mertsatilmaz Date: Mon, 13 Apr 2026 08:29:49 +0100 Subject: [PATCH 3/3] fix: use dirs::config_dir() for platform-correct config path Replace manual HOME/USERPROFILE env var lookup with dirs::config_dir() which handles macOS ~/Library/Application Support, Linux XDG, and Windows AppData correctly. --- Cargo.lock | 7 ++++--- llmfit-tui/Cargo.toml | 1 + llmfit-tui/src/filter_config.rs | 10 +--------- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 460b5c70..40f0899c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2258,13 +2258,14 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "llmfit" -version = "0.9.3" +version = "0.9.5" dependencies = [ "arboard", "axum", "clap", "colored", "crossterm", + "dirs", "http-body-util", "llmfit-core", "ratatui", @@ -2277,7 +2278,7 @@ dependencies = [ [[package]] name = "llmfit-core" -version = "0.9.3" +version = "0.9.5" dependencies = [ "serde", "serde_json", @@ -2288,7 +2289,7 @@ dependencies = [ [[package]] name = "llmfit-desktop" -version = "0.9.3" +version = "0.9.5" dependencies = [ "llmfit-core", "serde", diff --git a/llmfit-tui/Cargo.toml b/llmfit-tui/Cargo.toml index 0b8412d3..bb0bd7ea 100644 --- a/llmfit-tui/Cargo.toml +++ b/llmfit-tui/Cargo.toml @@ -26,6 +26,7 @@ colored = "3.1" ratatui = "0.30" crossterm = "0.29" arboard = "3.4" +dirs = "6.0" axum = "0.8" tokio = { version = "1.50", features = ["rt-multi-thread", "signal", "net"] } diff --git a/llmfit-tui/src/filter_config.rs b/llmfit-tui/src/filter_config.rs index 8efbef55..0cfcd0a5 100644 --- a/llmfit-tui/src/filter_config.rs +++ b/llmfit-tui/src/filter_config.rs @@ -33,15 +33,7 @@ pub struct FilterConfig { impl FilterConfig { /// Path to the config file: `~/.config/llmfit/filters.json` fn config_path() -> Option { - let home = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .ok()?; - Some( - PathBuf::from(home) - .join(".config") - .join("llmfit") - .join("filters.json"), - ) + Some(dirs::config_dir()?.join("llmfit").join("filters.json")) } /// Load the saved filter config from disk, falling back to defaults.