Vectrum
Sign In
619 lines · 21.4 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.
    Press,
    /// Holding the keys, or pressing them twice quickly, quits.
    #[default]
    Hold,
}

/// 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 {
    #[default]
    Tokens,
    /// Each paragraph or code block once it's complete.
    Paragraphs,
}

/// 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.
    #[default]
    Steer,
    /// Hold it until the turn ends.
    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,
    pub value: 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>,
}

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(),
        }
    }

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

/// 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>,

    // ---- 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,
    /// 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,
    /// Send with Ctrl+Enter, so Enter starts a new line.
    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,

    // ---- 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: turns on the legacy
    /// sidebar, like `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 ----
    /// 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(),
            default_model: None,
            default_reasoning: None,
            default_access: None,
            workspace: WorkspaceMode::Local,
            submodules: Submodules::Recursive,
            start_from_origin: false,
            branch_naming: BranchNaming::Static,
            branch_prefix: "vectrum".into(),
            branch_instructions: String::new(),
            worktrees_dir: String::new(),
            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_with_ctrl_enter: false,
            follow_up: FollowUp::Steer,
            streaming: Streaming::Tokens,
            slash_skills: true,
            rich_composer: true,
            collapse_composer: false,
            time_format: TimeFormat::System,
            proactive_panels: false,
            hide_whitespace: true,
            diff_collapsed: false,
            diff_side_by_side: false,
            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: false,
            background_activity: BackgroundActivity::Balanced,
            health_check_secs: 300,
            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);
            }
        }
    }

    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 mut settings: Settings = serde_json::from_str(text).unwrap_or_default();
    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, 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),
            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;
    });
}

/// `~/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::{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"));
    }
}