How llmfit Serializes and Reloads TUI Filter State Across Sessions

The llmfit TUI persists filter configurations to a JSON file at ~/.config/llmfit/filters.json using the FilterConfig struct, which handles deserialization on startup via load() and serialization on exit via save(), while apply_map() and build_map() translate between HashMap storage and Vec UI state.

The llmfit repository provides a terminal user interface for managing language model comparisons, and preserving user filter preferences between sessions requires a durable persistence layer. According to the source code in llmfit-tui/src/filter_config.rs, the application implements a robust three-stage pipeline that converts in-memory UI state to JSON and back, gracefully handling missing files or changed model data without breaking user preferences.

The Three-Stage Persistence Pipeline

The serialization architecture operates through distinct load, apply, and save phases orchestrated by the App struct in llmfit-tui/src/tui_app.rs.

Stage 1: Loading Configuration from Disk

When the TUI initializes, App::with_specs_context_and_config invokes FilterConfig::load() to retrieve previously saved preferences. The method constructs the configuration path using the dirs crate and attempts to deserialize the JSON contents:

pub fn load() -> Self {
    Self::config_path()
        .and_then(|p| fs::read_to_string(p).ok())
        .and_then(|s| serde_json::from_str(&s).ok())
        .unwrap_or_default()
}

If the file at ~/.config/llmfit/filters.json is missing or malformed, the method returns FilterConfig::default(), ensuring the TUI always starts with a valid filter set. The underlying path resolution occurs in config_path():

fn config_path() -> Option<PathBuf> {
    Some(dirs::config_dir()?.join("llmfit").join("filters.json"))
}

Stage 2: Mapping Stored Values to UI State

After loading, the raw FilterConfig data must align with the runtime UI structures. Simple enum filters like FitFilter convert directly from stored string labels using FitFilter::from_label, while multi-select filters require positional mapping.

The FilterConfig::apply_map method synchronizes persisted HashMap<String, bool> entries with the UI's parallel Vec<bool> vectors:

pub fn apply_map(names: &[String], selected: &mut [bool], saved: &HashMap<String, bool>) {
    for (i, name) in names.iter().enumerate() {
        if let Some(&val) = saved.get(name) {
            selected[i] = val;
        }
    }
}

This approach accommodates dynamic model lists—if a provider name disappears from the available set, its entry in the map is simply ignored during restoration.

Stage 3: Serializing Current State on Exit

When the user exits the TUI (triggered by Ctrl-C in llmfit-tui/src/tui_events.rs), App::save_filters constructs a fresh FilterConfig instance from the current UI fields. The method uses FilterConfig::build_map to convert Vec<bool> selections back into serializable HashMap<String, bool> structures:

pub fn build_map(names: &[String], selected: &[bool]) -> HashMap<String, bool> {
    names.iter().zip(selected.iter())
         .map(|(n, &s)| (n.clone(), s))
         .collect()
}

The save() method then writes pretty-printed JSON to disk, creating parent directories if necessary:

pub fn save(&self) {
    if let Some(path) = Self::config_path() {
        let _ = fs::create_dir_all(path.parent().unwrap());
        if let Ok(json) = serde_json::to_string_pretty(self) {
            let _ = fs::write(path, json);
        }
    }
}

Integration with the Application Lifecycle

The persistence layer integrates at critical points in the TUI lifecycle defined in llmfit-tui/src/tui_app.rs.

Startup Restoration occurs within App::with_specs_context_and_config (lines 81-109), where the code retrieves the saved fit level and restores provider, use-case, and capability selections via apply_map. The implementation preserves existing values like download_dir by reloading the current configuration before modification.

Exit Serialization is handled by App::save_filters (lines 93-122), which captures the current state of all filter widgets, reconstructs the FilterConfig struct, and invokes config.save(). This ensures every session's filter adjustments persist to the next invocation.

Data Format and Version Tolerance

The JSON schema stores enum variants as string labels and multi-select states as name-to-boolean mappings. This design provides natural version tolerance—new filter categories added to future releases simply deserialize to None or default values in older saved files, while obsolete entries in existing JSON are ignored during the mapping phase.

Example saved configuration structure:

let config = FilterConfig {
    fit_filter: Some(self.fit_filter.label().to_string()),
    providers: Some(FilterConfig::build_map(&self.providers, &self.selected_providers)),
    use_cases: Some(FilterConfig::build_map(&self.use_cases, &self.selected_use_cases)),
    capabilities: Some(FilterConfig::build_map(&self.capabilities, &self.selected_capabilities)),
    download_dir: FilterConfig::load().download_dir,
};

Testing Round-Trip Integrity

The codebase includes verification logic ensuring serialization fidelity. A round-trip test demonstrates saving and reloading preserves exact filter states:

#[test]
fn filter_state_roundtrip() {
    let mut original = FilterConfig::default();
    original.fit_filter = Some(FitFilter::Perfect.label().to_string());
    original.providers = Some(FilterConfig::build_map(&vec!["ollama".into()], &[true]));
    original.save();

    let loaded = FilterConfig::load();
    assert_eq!(original.fit_filter, loaded.fit_filter);
    assert_eq!(original.providers, loaded.providers);
}

Summary

  • Storage Location: Filter state persists to ~/.config/llmfit/filters.json using platform-appropriate configuration directories via the dirs crate.
  • Serialization: The FilterConfig struct uses serde_json for human-readable JSON output, with save() creating parent directories automatically.
  • Deserialization: The load() method implements fault-tolerant parsing that defaults to empty filters on error or missing files.
  • State Mapping: apply_map() and build_map() translate between stable HashMap<String, bool> storage and runtime Vec<bool> UI vectors, accommodating dynamic data sets.
  • Lifecycle Hooks: llmfit-tui/src/tui_app.rs orchestrates restoration at startup and llmfit-tui/src/tui_events.rs triggers persistence on exit.

Frequently Asked Questions

Where does llmfit store the TUI filter configuration?

The application stores filter state in a JSON file located at ~/.config/llmfit/filters.json (or the platform equivalent returned by dirs::config_dir()). The FilterConfig::config_path() method in llmfit-tui/src/filter_config.rs constructs this path dynamically by joining the system configuration directory with the application-specific filename.

What happens if the filters.json file is corrupted or deleted?

If FilterConfig::load() encounters a missing file or parsing error, it returns FilterConfig::default() via unwrap_or_default(), providing empty filters rather than crashing. This fallback behavior ensures the TUI remains usable even when the configuration is absent, malformed, or generated by an incompatible version of the application.

How does llmfit handle filter persistence when the available models change?

The system uses name-based HashMap storage for multi-select filters rather than positional indices. During restoration, apply_map() only updates UI positions where the saved name matches the current available set, silently ignoring obsolete entries. This prevents deserialization errors when model providers are added or removed between sessions, as unknown keys in the JSON map are simply skipped during the reconciliation process.

Which Rust crates enable the serialization in llmfit-tui?

The persistence layer relies on serde for derive macros and serde_json for JSON formatting, as specified in the project's Cargo.toml. The dirs crate resolves the appropriate configuration directory across Windows, macOS, and Linux platforms, ensuring the filters.json file writes to the correct user-specific location on each operating system.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →