Vectrum
Sign In
894 lines · 34.2 KB · Rust
//! Preferences that outlive the app, kept in
//! `$XDG_CONFIG_HOME/vectrum-code/settings.json` and edited in the settings
//! view. They're read once and cached, so rendering can consult them freely.

use std::{path::PathBuf, sync::Mutex};

use serde::{Deserialize, Serialize};

use crate::models::Provider;

/// Where a new thread starts: in the project folder, or in a git worktree of
/// its own on a fresh branch.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceMode {
    #[default]
    Local,
    Worktree,
}

/// How a new worktree fills in git submodules.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Submodules {
    Skip,
    TopLevel,
    #[default]
    Recursive,
}

/// How a new worktree's branch is named from the thread's first message.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BranchNaming {
    /// `branch_prefix` plus a few words the text model picks: `vectrum/add-search`.
    #[default]
    Static,
    /// The model picks a prefix that describes the work: `feat/add-search`.
    Semantic,
    /// `branch_instructions` tell the model the whole name.
    Custom,
}

/// What Ctrl+Q takes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum QuitShortcut {
    /// One press quits ("Direct").
    Press,
    /// Holding the keys, or pressing them twice quickly, quits.
    #[default]
    Hold,
    /// Two quick presses quit.
    Double,
}

/// Which keys send a prompt from the composer.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SendShortcut {
    /// Enter sends, Shift+Enter starts a new line.
    #[default]
    Enter,
    /// Enter sends a one-line prompt; once the prompt has several lines,
    /// Enter starts a new line and Ctrl+Enter sends.
    ModEnterMultiline,
    /// Ctrl+Enter sends, Enter starts a new line.
    ModEnter,
}

/// The colors diffs and change counts use for additions and deletions.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DiffColors {
    #[default]
    RedGreen,
    /// Easier to tell apart with red-green color blindness.
    BlueOrange,
}

/// How wide the conversation and the composer may grow on large screens.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChatWidth {
    #[default]
    Comfortable,
    Wide,
    Full,
}

/// How projects are ordered in the sidebar and the project menus.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProjectOrder {
    /// The project with the most recent message first.
    #[default]
    LastMessage,
    Name,
    Added,
    /// The order projects were added in, as the legacy sidebar's Manual sort.
    Manual,
}

/// Which projects share a row in the legacy sidebar.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProjectGrouping {
    /// Projects from the same repository share one row.
    #[default]
    Repository,
    /// Projects group only when both the repository and the path inside it match.
    RepositoryPath,
    /// Every project path gets its own row.
    Separate,
}

impl ProjectGrouping {
    pub const ALL: [ProjectGrouping; 3] =
        [ProjectGrouping::Repository, ProjectGrouping::RepositoryPath, ProjectGrouping::Separate];

    pub fn label(self) -> &'static str {
        match self {
            ProjectGrouping::Repository => "Group by repository",
            ProjectGrouping::RepositoryPath => "Group by repository path",
            ProjectGrouping::Separate => "Keep separate",
        }
    }

    pub fn description(self) -> &'static str {
        match self {
            ProjectGrouping::Repository => "Projects from the same repository share one sidebar row.",
            ProjectGrouping::RepositoryPath => {
                "Projects group only when both the repository and repo-relative path match."
            }
            ProjectGrouping::Separate => "Every project path gets its own sidebar row.",
        }
    }
}

/// How the legacy sidebar orders the threads under each project.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ThreadOrder {
    /// The thread with the most recent message first.
    #[default]
    LastMessage,
    Created,
}

/// Which of the two chosen themes shows.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ThemeMode {
    Light,
    Dark,
    /// Whichever the desktop is using.
    #[default]
    System,
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TimeFormat {
    /// Whatever the locale prefers.
    #[default]
    System,
    Hour12,
    Hour24,
}

/// How an answer appears while the agent writes it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Streaming {
    /// Every token as it arrives.
    Tokens,
    /// Each paragraph or code block once it's complete.
    #[default]
    Paragraphs,
    /// The whole answer once the turn ends.
    Turn,
}

/// What a message sent while the agent works does.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FollowUp {
    /// Hand it to the agent at once, so it can change course mid-turn.
    Steer,
    /// Hold it until the turn ends.
    #[default]
    Queue,
}

/// How eagerly the app checks on things in the background.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BackgroundActivity {
    Always,
    /// Pauses while the window has been in the background for a while, or
    /// the machine saves power.
    #[default]
    Balanced,
    /// Pauses whenever the window is in the background.
    Minimal,
}

#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct EnvVar {
    pub key: String,
    /// Empty for a sensitive variable: its value lives in the secrets file.
    pub value: String,
    /// Kept out of `settings.json`, in a file only this user can read, and
    /// never shown again once saved.
    pub sensitive: bool,
}

/// A model added by hand to an instance's list, for models the agent
/// doesn't report.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CustomModel {
    /// What the agent is launched with: `claude-sonnet-5`, `anthropic/claude-sonnet-5`.
    pub slug: String,
    /// Empty shows the slug.
    pub name: String,
}

/// One configured agent: Claude Code or Pi, with its own binary, home
/// folder, arguments and environment. There can be several of a kind, say
/// two Claude accounts.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ProviderInstance {
    /// Stable id, used in model keys (`claude:opus`). The first instance of a
    /// kind is named after it.
    pub id: String,
    pub kind: Provider,
    /// Empty shows the kind's name.
    pub name: String,
    pub enabled: bool,
    /// Empty looks the binary up on PATH.
    pub binary: String,
    /// `CLAUDE_CONFIG_DIR` or `PI_CODING_AGENT_DIR`; empty is the default.
    pub home: String,
    /// Extra arguments, split like a shell would.
    pub args: String,
    pub env: Vec<EnvVar>,
    /// A hex color (`#3e9bfa`) marking the instance in lists and pickers;
    /// empty for none.
    pub accent: String,
    /// Claude: compact the conversation after this many tokens (100,000 to
    /// 1,000,000); empty keeps Claude's default.
    pub auto_compact: String,
    pub custom_models: Vec<CustomModel>,
}

impl Default for ProviderInstance {
    fn default() -> Self {
        Self::new(Provider::Claude, "claude")
    }
}

impl ProviderInstance {
    pub fn new(kind: Provider, id: &str) -> Self {
        Self {
            id: id.to_string(),
            kind,
            name: String::new(),
            enabled: true,
            binary: String::new(),
            home: String::new(),
            args: String::new(),
            env: Vec::new(),
            accent: String::new(),
            auto_compact: String::new(),
            custom_models: Vec::new(),
        }
    }

    pub fn label(&self) -> String {
        if self.name.trim().is_empty() { self.kind.label().to_string() } else { self.name.trim().to_string() }
    }

    /// The variables the agent runs with, sensitive values read from the
    /// secrets file.
    pub fn env_vars(&self) -> Vec<(String, String)> {
        let secrets = if self.env.iter().any(|var| var.sensitive) { read_secrets() } else { Default::default() };
        self.env
            .iter()
            .filter(|var| !var.key.trim().is_empty())
            .map(|var| {
                let key = var.key.trim().to_string();
                let value = match var.sensitive {
                    true => secrets.get(&secret_key(&self.id, &key)).cloned().unwrap_or_default(),
                    false => var.value.clone(),
                };
                (key, value)
            })
            .collect()
    }
}

/// Another device's app this one connects to.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct RemoteHost {
    pub id: String,
    pub name: String,
    /// `host` or `host:port`.
    pub address: String,
    pub key: String,
    pub enabled: bool,
}

impl RemoteHost {
    pub fn label(&self) -> String {
        if self.name.trim().is_empty() { self.address.clone() } else { self.name.trim().to_string() }
    }
}

/// A usage hub the Usage page also reads, such as a CLIProxyAPI server.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct UsageHub {
    pub name: String,
    /// `cliproxy`.
    pub kind: String,
    pub url: String,
    /// The hub's management key.
    pub key: String,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
    pub theme_mode: ThemeMode,
    /// The theme light mode uses, by id (`bone`, `catppuccin-latte`).
    pub light_theme: String,
    /// The theme dark mode uses, by id (`graphite`, `tokyo-night`).
    pub dark_theme: String,
    /// Model keys (`claude:opus`, `pi:anthropic/claude-sonnet-5-5`), in the
    /// order they were starred.
    pub favorites: Vec<String>,
    /// Model keys the model picker leaves out.
    pub hidden_models: Vec<String>,
    /// Model keys in the order the picker lists them; models not in it
    /// follow in the agent's order.
    pub model_order: Vec<String>,

    // ---- new threads ----
    /// The model key new threads start on. `None` follows the open thread.
    pub default_model: Option<String>,
    /// `default`, an effort level (`high`), `ultracode` or `ultrathink`.
    /// `None` follows the open thread.
    pub default_reasoning: Option<String>,
    /// A permission mode as the CLI spells it. `None` follows the open thread.
    pub default_access: Option<String>,
    pub workspace: WorkspaceMode,
    pub submodules: Submodules,
    /// Base new worktrees on the branch as origin has it.
    pub start_from_origin: bool,
    /// How new worktree branches are named.
    pub branch_naming: BranchNaming,
    /// The prefix static branch names start with; empty for none.
    pub branch_prefix: String,
    /// What custom branch naming tells the model.
    pub branch_instructions: String,
    /// Where new worktrees go; empty is the app's data folder.
    pub worktrees_dir: String,
    /// Remove a deleted thread's worktree without asking, unless it has
    /// local changes.
    pub cleanup_deleted_worktrees: bool,
    /// Remove a worktree once its thread has been inactive this many days.
    pub cleanup_inactive_worktrees: Option<u32>,
    /// Remove a worktree whose pull request merged into the default branch.
    pub cleanup_merged_worktrees: bool,
    /// Remove a worktree with no commits beyond the default branch.
    pub cleanup_unchanged_worktrees: bool,
    /// Keep the branch and worktree strip under the composer once a thread
    /// has started, not only on new threads.
    pub composer_context: bool,
    /// Claude's plan mode as a Build/Plan toggle in the composer, with
    /// Shift+Tab to switch.
    pub plan_mode: bool,
    /// The ring showing how full the context window is.
    pub context_meter: bool,
    /// Where the Add project browser opens; empty is the home folder.
    pub add_project_dir: String,

    // ---- text generation ----
    /// The model key thread titles are written with; empty keeps the first
    /// line of the first message.
    pub text_model: String,
    /// The reasoning level for generated text, as [`Settings::default_reasoning`].
    pub text_reasoning: Option<String>,

    // ---- conversation ----
    /// Fold a finished turn's tool calls into one "Worked for" row.
    pub fold_turns: bool,
    /// Show what threads and turns cost.
    pub show_costs: bool,
    pub send_shortcut: SendShortcut,
    /// Send with Ctrl+Enter, so Enter starts a new line. Follows
    /// `send_shortcut` (true for [`SendShortcut::ModEnter`]).
    pub send_with_ctrl_enter: bool,
    pub follow_up: FollowUp,
    pub streaming: Streaming,
    /// List skills in the `/` menu too; `$` always lists them.
    pub slash_skills: bool,
    /// Highlight Markdown in the composer as you type.
    pub rich_composer: bool,
    /// Rest the composer to one line while scrolling a conversation.
    pub collapse_composer: bool,
    pub time_format: TimeFormat,
    /// Open the diff after a turn that changed enough.
    pub proactive_panels: bool,
    /// Ignore whitespace-only edits in diffs.
    pub hide_whitespace: bool,
    /// Open diffs with every file folded.
    pub diff_collapsed: bool,
    pub diff_side_by_side: bool,

    // ---- appearance ----
    pub diff_colors: DiffColors,
    pub chat_width: ChatWidth,

    // ---- sidebar ----
    /// T3 Code's legacy sidebar: threads in per-project trees instead of the
    /// default flat inbox.
    pub legacy_sidebar: bool,
    /// How the legacy sidebar orders threads under a project.
    pub thread_order: ThreadOrder,
    /// How many threads the legacy sidebar shows per project before "Show more".
    pub visible_threads: u32,
    /// Which projects share a row in the legacy sidebar.
    pub project_grouping: ProjectGrouping,
    /// Per-project grouping rules, by project path, overriding the default.
    pub project_grouping_overrides: std::collections::BTreeMap<String, ProjectGrouping>,
    /// One-line thread cards in the sidebar.
    pub compact_threads: bool,
    /// Group sidebar threads under their project. Follows `legacy_sidebar`.
    pub group_by_project: bool,
    pub project_order: ProjectOrder,
    /// Fold working threads into their own section until they need you.
    pub working_section: bool,
    /// Continue a thread stopped by a usage limit once the limit resets.
    pub auto_resume_limited: bool,
    /// Snooze a thread stopped by a usage limit until the limit resets.
    pub snooze_limited: bool,
    /// Settle a worktree thread once its pull request merges.
    pub auto_settle_merged: bool,
    pub auto_settle_inactive: bool,
    pub inactive_days: u32,

    // ---- confirmations ----
    pub confirm_unpin: bool,
    pub confirm_archive: bool,
    /// Settling from a thread card takes a second click.
    pub confirm_settle: bool,
    pub confirm_delete: bool,
    pub quit_shortcut: QuitShortcut,

    // ---- notifications and background work ----
    /// A desktop notification when a thread finishes or needs you while the
    /// window is in the background.
    pub notify: bool,
    /// Play a sound with each desktop notification.
    pub notify_sound: bool,
    /// A toast when another thread finishes or needs you while the window
    /// has focus.
    pub in_app_notifications: bool,
    /// Resume threads a restart or crash interrupted.
    pub continue_after_restart: bool,
    /// Check installed agents for newer versions.
    pub update_checks: bool,
    pub background_activity: BackgroundActivity,
    /// Seconds between provider health checks; 0 turns them off.
    pub health_check_secs: u64,

    // ---- integrations ----
    /// Let agents use the browser panel's shared browser.
    pub agent_browser: bool,
    /// The editor "Open in" uses, by name (`Zed`).
    pub editor: Option<String>,
    pub providers: Vec<ProviderInstance>,
    pub usage_hubs: Vec<UsageHub>,

    // ---- remote connections ----
    /// Let other devices' apps connect to this one.
    pub remote_server: bool,
    pub remote_port: u16,
    /// The key other devices connect with; made when first needed.
    pub remote_key: String,
    /// Other devices' apps this one connects to.
    pub remotes: Vec<RemoteHost>,

    /// Superseded by `providers`; read once to carry an old path override over.
    #[serde(skip_serializing)]
    claude_bin: Option<String>,
    #[serde(skip_serializing)]
    pi_bin: Option<String>,
    /// Superseded by the theme fields: `bone`, `auto` or `graphite`.
    #[serde(skip_serializing)]
    colorway: Option<String>,
}

impl Default for Settings {
    fn default() -> Self {
        Self {
            theme_mode: ThemeMode::System,
            light_theme: "bone".into(),
            dark_theme: "graphite".into(),
            favorites: Vec::new(),
            hidden_models: Vec::new(),
            model_order: Vec::new(),
            default_model: None,
            default_reasoning: None,
            default_access: None,
            workspace: WorkspaceMode::Local,
            submodules: Submodules::Recursive,
            start_from_origin: true,
            branch_naming: BranchNaming::Static,
            branch_prefix: "vectrum".into(),
            branch_instructions: String::new(),
            worktrees_dir: String::new(),
            cleanup_deleted_worktrees: false,
            cleanup_inactive_worktrees: None,
            cleanup_merged_worktrees: false,
            cleanup_unchanged_worktrees: false,
            composer_context: false,
            plan_mode: false,
            context_meter: false,
            add_project_dir: String::new(),
            text_model: "claude:haiku".into(),
            text_reasoning: Some("low".into()),
            fold_turns: true,
            show_costs: true,
            send_shortcut: SendShortcut::Enter,
            send_with_ctrl_enter: false,
            follow_up: FollowUp::Queue,
            streaming: Streaming::Paragraphs,
            slash_skills: true,
            rich_composer: true,
            collapse_composer: true,
            time_format: TimeFormat::System,
            proactive_panels: false,
            hide_whitespace: true,
            diff_collapsed: true,
            diff_side_by_side: false,
            diff_colors: DiffColors::RedGreen,
            chat_width: ChatWidth::Comfortable,
            legacy_sidebar: false,
            thread_order: ThreadOrder::LastMessage,
            visible_threads: 6,
            project_grouping: ProjectGrouping::Repository,
            project_grouping_overrides: std::collections::BTreeMap::new(),
            compact_threads: false,
            group_by_project: false,
            project_order: ProjectOrder::LastMessage,
            working_section: false,
            auto_resume_limited: false,
            snooze_limited: false,
            auto_settle_merged: true,
            auto_settle_inactive: false,
            inactive_days: 3,
            confirm_unpin: false,
            confirm_archive: false,
            confirm_settle: false,
            confirm_delete: true,
            quit_shortcut: QuitShortcut::Hold,
            notify: false,
            notify_sound: false,
            in_app_notifications: false,
            continue_after_restart: false,
            update_checks: true,
            background_activity: BackgroundActivity::Balanced,
            health_check_secs: 300,
            agent_browser: true,
            editor: None,
            providers: Vec::new(),
            usage_hubs: Vec::new(),
            remote_server: false,
            remote_port: crate::remote::DEFAULT_PORT,
            remote_key: String::new(),
            remotes: Vec::new(),
            claude_bin: None,
            pi_bin: None,
            colorway: None,
        }
    }
}

impl Settings {
    /// Makes sure there's at least one instance of each kind, carrying old
    /// binary overrides over, and turns an old colorway into a theme mode.
    fn normalize(&mut self) {
        if let Some(colorway) = self.colorway.take() {
            self.theme_mode = match colorway.as_str() {
                "bone" => ThemeMode::Light,
                "graphite" => ThemeMode::Dark,
                _ => ThemeMode::System,
            };
        }
        for (kind, old_bin) in [(Provider::Claude, self.claude_bin.take()), (Provider::Pi, self.pi_bin.take())] {
            if !self.providers.iter().any(|instance| instance.kind == kind) {
                let mut instance = ProviderInstance::new(kind, kind.key());
                instance.binary = old_bin.unwrap_or_default();
                self.providers.push(instance);
            }
        }
        // Fields older code reads, kept in step with the ones replacing them.
        self.send_with_ctrl_enter = self.send_shortcut == SendShortcut::ModEnter;
        self.group_by_project = self.legacy_sidebar;
    }

    /// The settings "Restore device defaults" would change, by their
    /// labels on the settings pages, in the order T3 Code lists them.
    pub fn differing_defaults(&self) -> Vec<&'static str> {
        let d = Settings::default();
        let theme = (self.theme_mode, &self.light_theme, &self.dark_theme) != (d.theme_mode, &d.light_theme, &d.dark_theme);
        [
            ("Theme", theme),
            ("Diff colors", self.diff_colors != d.diff_colors),
            ("Chat width", self.chat_width != d.chat_width),
            ("Time format", self.time_format != d.time_format),
            ("Thread notifications", (self.notify, self.notify_sound) != (d.notify, d.notify_sound)),
            ("In-app notifications", self.in_app_notifications != d.in_app_notifications),
            ("Project Grouping", self.project_grouping != d.project_grouping),
            ("Project order", self.project_order != d.project_order),
            ("Working section", self.working_section != d.working_section),
            (
                "Auto-settle inactive threads",
                (self.auto_settle_inactive, self.inactive_days) != (d.auto_settle_inactive, d.inactive_days),
            ),
            ("Auto-settle merged threads", self.auto_settle_merged != d.auto_settle_merged),
            ("Auto-resume limited threads", self.auto_resume_limited != d.auto_resume_limited),
            ("Snooze limited threads", self.snooze_limited != d.snooze_limited),
            ("Composer context", self.composer_context != d.composer_context),
            ("Thread cards", self.compact_threads != d.compact_threads),
            ("Default diff file state", self.diff_collapsed != d.diff_collapsed),
            ("Diff whitespace changes", self.hide_whitespace != d.hide_whitespace),
            ("Diff layout", self.diff_side_by_side != d.diff_side_by_side),
            ("Proactive panels", self.proactive_panels != d.proactive_panels),
            ("Show skills in slash menu", self.slash_skills != d.slash_skills),
            ("Collapse composer on scroll", self.collapse_composer != d.collapse_composer),
            ("Rich text composer", self.rich_composer != d.rich_composer),
            ("Send shortcut", self.send_shortcut != d.send_shortcut),
            ("Follow-up behavior", self.follow_up != d.follow_up),
            ("Context window indicator", self.context_meter != d.context_meter),
            ("Plan mode", self.plan_mode != d.plan_mode),
            ("Sidebar", self.legacy_sidebar != d.legacy_sidebar),
            ("Response streaming", self.streaming != d.streaming),
            ("Fold finished turns", self.fold_turns != d.fold_turns),
            ("Show costs", self.show_costs != d.show_costs),
            ("Provider update checks", self.update_checks != d.update_checks),
            ("Continue threads after restarts", self.continue_after_restart != d.continue_after_restart),
            ("Background activity", self.background_activity != d.background_activity),
            ("Health check interval", self.health_check_secs != d.health_check_secs),
            ("Default model", (&self.default_model, &self.default_reasoning) != (&d.default_model, &d.default_reasoning)),
            ("Permissions", self.default_access != d.default_access),
            ("New thread mode", self.workspace != d.workspace),
            ("Submodules", self.submodules != d.submodules),
            ("New worktrees start from origin", self.start_from_origin != d.start_from_origin),
            ("Add project base directory", self.add_project_dir != d.add_project_dir),
            ("Unpin confirmation", self.confirm_unpin != d.confirm_unpin),
            ("Archive confirmation", self.confirm_settle != d.confirm_settle),
            ("Delete confirmation", self.confirm_delete != d.confirm_delete),
            ("Quit shortcut", self.quit_shortcut != d.quit_shortcut),
            ("Text generation model", (&self.text_model, &self.text_reasoning) != (&d.text_model, &d.text_reasoning)),
            (
                "Worktree branch naming",
                (self.branch_naming, &self.branch_prefix, &self.branch_instructions)
                    != (d.branch_naming, &d.branch_prefix, &d.branch_instructions),
            ),
            ("Worktree location", self.worktrees_dir != d.worktrees_dir),
            ("Agent browser access", self.agent_browser != d.agent_browser),
            ("Open in", self.editor != d.editor),
        ]
        .into_iter()
        .filter_map(|(label, differs)| differs.then_some(label))
        .collect()
    }

    pub fn instance(&self, id: &str) -> Option<&ProviderInstance> {
        self.providers.iter().find(|instance| instance.id == id)
    }
}

static CACHE: Mutex<Option<Settings>> = Mutex::new(None);

pub fn path() -> Option<PathBuf> {
    let base = std::env::var_os("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?;
    Some(base.join("vectrum-code").join("settings.json"))
}

fn read_file() -> Settings {
    parse(&path().and_then(|path| std::fs::read_to_string(path).ok()).unwrap_or_default())
}

/// Reads a settings file's text, falling back to the defaults.
fn parse(text: &str) -> Settings {
    let raw: serde_json::Value = serde_json::from_str(text).unwrap_or_default();
    let mut settings: Settings = serde_json::from_value(raw.clone()).unwrap_or_default();
    // Carry settings over from the fields that came before these.
    let missing = |key: &str| raw.get(key).is_none();
    if missing("send_shortcut") && settings.send_with_ctrl_enter {
        settings.send_shortcut = SendShortcut::ModEnter;
    }
    if missing("legacy_sidebar") {
        settings.legacy_sidebar = settings.group_by_project;
    }
    settings.normalize();
    settings
}

/// Reads one value out of the settings.
pub fn get<T>(read: impl FnOnce(&Settings) -> T) -> T {
    let mut cache = CACHE.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
    read(cache.get_or_insert_with(read_file))
}

pub fn load() -> Settings {
    get(Settings::clone)
}

/// The configured agents, enabled or not.
pub fn instances() -> Vec<ProviderInstance> {
    get(|settings| settings.providers.clone())
}

/// One agent's configuration, falling back to a default for its kind.
pub fn instance(id: &str, kind: Provider) -> ProviderInstance {
    get(|settings| settings.instance(id).cloned()).unwrap_or_else(|| ProviderInstance::new(kind, id))
}

/// Applies `edit` to the settings and writes them back.
pub fn update(edit: impl FnOnce(&mut Settings)) {
    let settings = {
        let mut cache = CACHE.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
        let settings = cache.get_or_insert_with(read_file);
        edit(settings);
        settings.normalize();
        settings.clone()
    };
    let Some(path) = path() else { return };
    let written = path
        .parent()
        .map(std::fs::create_dir_all)
        .unwrap_or(Ok(()))
        .and_then(|_| std::fs::write(&path, serde_json::to_string_pretty(&settings).unwrap_or_default()));
    if let Err(err) = written {
        eprintln!("vectrum-code: couldn't save {}: {err}", path.display());
    }
}

/// Puts everything but favorites and model preferences, agents, usage hubs
/// and remote connections back to its default.
pub fn restore_defaults() {
    update(|settings| {
        let kept = Settings {
            favorites: std::mem::take(&mut settings.favorites),
            hidden_models: std::mem::take(&mut settings.hidden_models),
            model_order: std::mem::take(&mut settings.model_order),
            providers: std::mem::take(&mut settings.providers),
            usage_hubs: std::mem::take(&mut settings.usage_hubs),
            remote_server: settings.remote_server,
            remote_port: settings.remote_port,
            remote_key: std::mem::take(&mut settings.remote_key),
            remotes: std::mem::take(&mut settings.remotes),
            ..Settings::default()
        };
        *settings = kept;
    });
}

// ---- secrets ---------------------------------------------------------------

/// Where sensitive environment variables are kept: next to the settings,
/// readable by this user only.
fn secrets_path() -> Option<PathBuf> {
    Some(path()?.with_file_name("secrets.json"))
}

fn secret_key(instance: &str, var: &str) -> String {
    format!("{instance}/{var}")
}

fn read_secrets() -> std::collections::BTreeMap<String, String> {
    secrets_path()
        .and_then(|path| std::fs::read_to_string(path).ok())
        .and_then(|text| serde_json::from_str(&text).ok())
        .unwrap_or_default()
}

/// Whether a sensitive variable has a stored value.
pub fn has_secret(instance: &str, var: &str) -> bool {
    read_secrets().contains_key(&secret_key(instance, var.trim()))
}

/// Stores a sensitive variable's value, or forgets it with `None`.
pub fn set_secret(instance: &str, var: &str, value: Option<&str>) {
    let mut secrets = read_secrets();
    let key = secret_key(instance, var.trim());
    match value {
        Some(value) => secrets.insert(key, value.to_string()),
        None => secrets.remove(&key),
    };
    write_secrets(&secrets);
}

/// Keeps a sensitive variable's value when the variable is renamed.
pub fn rename_secret(instance: &str, from: &str, to: &str) {
    let mut secrets = read_secrets();
    if let Some(value) = secrets.remove(&secret_key(instance, from.trim())) {
        if !to.trim().is_empty() {
            secrets.insert(secret_key(instance, to.trim()), value);
        }
        write_secrets(&secrets);
    }
}

fn write_secrets(secrets: &std::collections::BTreeMap<String, String>) {
    let Some(path) = secrets_path() else { return };
    let written = path.parent().map(std::fs::create_dir_all).unwrap_or(Ok(())).and_then(|_| {
        use std::{io::Write as _, os::unix::fs::OpenOptionsExt as _};
        let mut file = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&path)?;
        file.write_all(serde_json::to_string_pretty(&secrets).unwrap_or_default().as_bytes())
    });
    if let Err(err) = written {
        eprintln!("vectrum-code: couldn't save {}: {err}", path.display());
    }
}

/// `~/bin/claude` → `/home/me/bin/claude`.
pub fn expand_home(path: &str) -> PathBuf {
    match (path.strip_prefix("~/"), std::env::var_os("HOME")) {
        (Some(rest), Some(home)) => PathBuf::from(home).join(rest),
        _ if path == "~" => std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(path)),
        _ => PathBuf::from(path),
    }
}

/// Where the app keeps its own data: threads, worktrees, scratch space.
pub fn data_dir() -> Option<PathBuf> {
    let base = std::env::var_os("XDG_DATA_HOME")
        .map(PathBuf::from)
        .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")))?;
    Some(base.join("vectrum-code"))
}

#[cfg(test)]
mod tests {
    use super::{SendShortcut, ThemeMode, parse};

    #[test]
    fn old_colorways_become_theme_modes() {
        for (colorway, mode) in [("bone", ThemeMode::Light), ("graphite", ThemeMode::Dark), ("auto", ThemeMode::System)] {
            let settings = parse(&format!(r#"{{"colorway": "{colorway}"}}"#));
            assert_eq!(settings.theme_mode, mode);
            assert_eq!(settings.light_theme, "bone");
            assert_eq!(settings.dark_theme, "graphite");
            assert!(!serde_json::to_string(&settings).unwrap().contains("colorway"));
        }
        assert_eq!(parse("{}").theme_mode, ThemeMode::System);
        let saved = parse(r#"{"theme_mode": "dark", "dark_theme": "nord"}"#);
        assert_eq!((saved.theme_mode, saved.dark_theme.as_str()), (ThemeMode::Dark, "nord"));
    }

    #[test]
    fn older_fields_carry_over() {
        let old = parse(r#"{"send_with_ctrl_enter": true, "group_by_project": true}"#);
        assert_eq!(old.send_shortcut, SendShortcut::ModEnter);
        assert!(old.legacy_sidebar && old.group_by_project);
        // The new fields win once they're saved, and the old ones follow them.
        let new = parse(r#"{"send_with_ctrl_enter": true, "send_shortcut": "mod_enter_multiline", "legacy_sidebar": false}"#);
        assert_eq!(new.send_shortcut, SendShortcut::ModEnterMultiline);
        assert!(!new.send_with_ctrl_enter && !new.group_by_project && !new.legacy_sidebar);
        let fresh = parse("{}");
        assert_eq!((fresh.send_shortcut, fresh.legacy_sidebar), (SendShortcut::Enter, false));
    }

    #[test]
    fn restore_lists_what_changed() {
        let mut settings = parse("{}");
        assert!(settings.differing_defaults().is_empty());
        settings.chat_width = super::ChatWidth::Wide;
        settings.dark_theme = "nord".into();
        assert_eq!(settings.differing_defaults(), ["Theme", "Chat width"]);
    }
}