//! 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, } 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, /// Model keys (`claude:opus`, `pi:anthropic/claude-sonnet-5-5`), in the /// order they were starred. pub favorites: Vec, // ---- new threads ---- /// The model key new threads start on. `None` follows the open thread. pub default_model: Option, /// `default`, an effort level (`high`), `ultracode` or `ultrathink`. /// `None` follows the open thread. pub default_reasoning: Option, /// A permission mode as the CLI spells it. `None` follows the open thread. pub default_access: Option, 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, // ---- 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, pub providers: Vec, pub usage_hubs: Vec, /// Superseded by `providers`; read once to carry an old path override over. #[serde(skip_serializing)] claude_bin: Option, #[serde(skip_serializing)] pi_bin: Option, } 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> = Mutex::new(None); pub fn path() -> Option { 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(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 { 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 { 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")) }