Vectrum
Sign In
429 lines · 14.3 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,
}

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

#[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() }
    }
}

/// 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 {
    /// `bone`, `auto` or `graphite`.
    pub colorway: Option<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,
    /// 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 ----
    /// One-line thread cards in the sidebar.
    pub compact_threads: bool,
    /// Group sidebar threads under their project.
    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,
    /// 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>,

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

impl Default for Settings {
    fn default() -> Self {
        Self {
            colorway: None,
            favorites: Vec::new(),
            default_model: None,
            default_reasoning: None,
            default_access: None,
            workspace: WorkspaceMode::Local,
            submodules: Submodules::Recursive,
            start_from_origin: 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,
            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_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(),
            claude_bin: None,
            pi_bin: None,
        }
    }
}

impl Settings {
    /// Makes sure there's at least one instance of each kind, carrying old
    /// binary overrides over.
    fn normalize(&mut self) {
        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 {
    let mut settings: Settings = path()
        .and_then(|path| std::fs::read_to_string(path).ok())
        .and_then(|text| serde_json::from_str(&text).ok())
        .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 and usage hubs back to its default.
pub fn restore_defaults() {
    update(|settings| {
        let favorites = std::mem::take(&mut settings.favorites);
        let providers = std::mem::take(&mut settings.providers);
        let usage_hubs = std::mem::take(&mut settings.usage_hubs);
        *settings = Settings { favorites, providers, usage_hubs, ..Settings::default() };
    });
}

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