//! 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::{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, } /// How generated commit messages and pull requests are written. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum WritingStyle { /// Like the repository's recent commits. #[default] RepoConventions, /// With Conventional Commit prefixes. Conventional, /// As `writing_instructions` say. 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, } /// Whose plan limits the bar under the composer shows. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum UsageBar { /// No bar. Off, #[default] Claude, Codex, } /// 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, /// 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, } 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, /// The name it gives itself, as it last said, to find it on the tailnet /// by when the user named it something else. #[serde(skip_serializing_if = "String::is_empty")] pub hostname: String, /// `host` or `host:port`. pub address: String, pub key: String, pub enabled: bool, /// Whether the user was asked, on first connecting, to give the device /// this one's environment settings. pub settings_offered: bool, /// Other ways to reach it: those it reported, such as its Tailscale /// address when paired over Wi-Fi, and others the user paired over. #[serde(skip_serializing_if = "Vec::is_empty")] pub routes: Vec, } impl RemoteHost { pub fn label(&self) -> String { if self.name.trim().is_empty() { self.address.clone() } else { self.name.trim().to_string() } } /// What to look it up on the tailnet by: the name the user gave it and /// the one it gives itself. pub fn lookup_names(&self) -> Vec { [&self.name, &self.hostname].into_iter().filter(|name| !name.trim().is_empty()).cloned().collect() } /// Every way to reach it, fastest first. pub fn ordered_routes(&self) -> Vec { crate::remote::ordered_routes(&self.address, &self.routes) } } /// 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, /// Model keys the model picker leaves out. pub hidden_models: Vec, /// Model keys in the order the picker lists them; models not in it /// follow in the agent's order. pub model_order: 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, /// 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, /// How generated commit messages and pull requests read. pub writing_style: WritingStyle, /// What the custom writing style tells the model. pub writing_instructions: String, /// Fill in the repository's pull request template, when it has one. pub follow_pr_templates: bool, /// 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, /// 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, /// The bar under the composer: plan limits, context and output speed. pub usage_bar: UsageBar, /// 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, 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, /// Draw diagrams, charts and small UIs from agents' answers, and tell /// agents they can write them. pub inline_ui: 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, /// An image behind the new-thread screen, by path; empty for none. /// Conversations keep the plain sheet. pub start_wallpaper: String, /// How much the sheet's color covers the wallpaper, in percent, so the /// heading stays legible. pub start_wallpaper_dim: u32, // ---- 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, /// 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, /// Ctrl+Q, Alt+F4 and the window's close button ask before closing. pub confirm_close: bool, // ---- app updates ---- /// Look for a new version of the app on vectrum.cc now and then. pub app_update_checks: bool, // ---- 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, /// Minutes an idle thread's agent process keeps running before it's /// stopped to free its memory; the next message resumes the /// conversation. 0 keeps them running. pub idle_agent_minutes: u64, // ---- integrations ---- /// Let agents use the browser panel's shared browser. pub agent_browser: bool, /// Float a device over the chat when an agent opens one, rather than /// opening it in the right panel. pub auto_float_preview: bool, /// The device hub: simulators and emulators in the Device panel. pub enable_device_support: bool, /// Let new agent sessions start and control devices. pub enable_agent_device_access: bool, /// The Device panel's setup was finished. pub device_onboarding_completed: bool, /// The editor "Open in" uses, by name (`Zed`). pub editor: Option, pub providers: Vec, pub usage_hubs: Vec, // ---- 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, /// Settings a project overrides, by project path: field names from /// [`PROJECT_KEYS`] and their values. pub project_overrides: std::collections::BTreeMap>, /// Superseded by `providers`; read once to carry an old path override over. #[serde(skip_serializing)] claude_bin: Option, #[serde(skip_serializing)] pi_bin: Option, /// Superseded by the theme fields: `bone`, `auto` or `graphite`. #[serde(skip_serializing)] colorway: Option, } 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(), writing_style: WritingStyle::RepoConventions, writing_instructions: String::new(), follow_pr_templates: true, 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, usage_bar: UsageBar::Claude, 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, inline_ui: 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, start_wallpaper: String::new(), start_wallpaper_dim: 30, 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, confirm_close: true, app_update_checks: true, notify: false, notify_sound: false, in_app_notifications: false, continue_after_restart: false, update_checks: true, background_activity: BackgroundActivity::Balanced, health_check_secs: 300, idle_agent_minutes: 15, agent_browser: true, auto_float_preview: true, enable_device_support: false, enable_agent_device_access: false, device_onboarding_completed: false, 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(), project_overrides: std::collections::BTreeMap::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. pub(crate) 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), ( "Start wallpaper", (&self.start_wallpaper, self.start_wallpaper_dim) != (&d.start_wallpaper, d.start_wallpaper_dim), ), ("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), ("Inline diagrams and UI", self.inline_ui != d.inline_ui), ("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), ("Usage bar", self.usage_bar != d.usage_bar), ("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), ("Stop idle agents", self.idle_agent_minutes != d.idle_agent_minutes), ("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), ("Close confirmation", self.confirm_close != d.confirm_close), ("Automatic update checks", self.app_update_checks != d.app_update_checks), ("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), ("Auto-show floating preview", self.auto_float_preview != d.auto_float_preview), ("Device hub", self.enable_device_support != d.enable_device_support), ("Agent device access", self.enable_agent_device_access != d.enable_agent_device_access), ("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) } /// These settings as one project sees them: with its overrides. pub fn for_project(&self, project: &Path) -> Settings { match self.project_overrides.get(&project.display().to_string()) { Some(overrides) => with_values(self, overrides), None => self.clone(), } } /// The settings an environment shares with the devices connected to it: /// [`ENVIRONMENT_KEYS`] and the project overrides. pub fn environment_values(&self) -> serde_json::Map { let serde_json::Value::Object(mut all) = serde_json::to_value(self).unwrap_or_default() else { return Default::default(); }; all.retain(|key, _| ENVIRONMENT_KEYS.contains(&key.as_str()) || key == "project_overrides"); all } /// Applies a change from the settings view or another device, keeping /// to the fields a patch may touch. pub fn apply_patch(&mut self, patch: &SettingsPatch) { let values: serde_json::Map<_, _> = patch .values .iter() .filter(|(key, _)| ENVIRONMENT_KEYS.contains(&key.as_str())) .map(|(key, value)| (key.clone(), value.clone())) .collect(); if !values.is_empty() { *self = with_values(self, &values); } for (project, changes) in &patch.projects { let overrides = self.project_overrides.entry(project.clone()).or_default(); for (key, value) in changes { if !PROJECT_KEYS.contains(&key.as_str()) { continue; } match value { serde_json::Value::Null => overrides.remove(key), value => overrides.insert(key.clone(), value.clone()), }; } if overrides.is_empty() { self.project_overrides.remove(project); } } self.normalize(); } } /// Settings an environment keeps for itself, as T3 Code's server settings: /// they shape the threads that run there, so they're edited on the machine /// the threads run on, one environment or all of them at once. Everything /// else is a preference of the device it was set on. pub const ENVIRONMENT_KEYS: &[&str] = &[ "default_model", "default_reasoning", "default_access", "workspace", "submodules", "start_from_origin", "branch_naming", "branch_prefix", "branch_instructions", "writing_style", "writing_instructions", "follow_pr_templates", "worktrees_dir", "cleanup_deleted_worktrees", "cleanup_inactive_worktrees", "cleanup_merged_worktrees", "cleanup_unchanged_worktrees", "text_model", "text_reasoning", "auto_resume_limited", "snooze_limited", "auto_settle_merged", "auto_settle_inactive", "inactive_days", "continue_after_restart", "update_checks", "agent_browser", ]; /// The environment settings a project can override. pub const PROJECT_KEYS: &[&str] = &[ "default_model", "default_reasoning", "default_access", "workspace", "submodules", "start_from_origin", "branch_naming", "branch_prefix", "branch_instructions", "writing_style", "writing_instructions", "follow_pr_templates", "cleanup_inactive_worktrees", "cleanup_merged_worktrees", "cleanup_unchanged_worktrees", "text_model", "text_reasoning", "auto_settle_merged", "auto_settle_inactive", "inactive_days", "agent_browser", ]; /// A change to an environment's settings: new values for its own fields, /// and per project path, overrides to set or (as `null`) to drop. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct SettingsPatch { pub values: serde_json::Map, pub projects: std::collections::BTreeMap>, } impl SettingsPatch { pub fn is_empty(&self) -> bool { self.values.is_empty() && self.projects.values().all(serde_json::Map::is_empty) } } /// `settings` with some fields replaced, by name. pub fn with_values(settings: &Settings, values: &serde_json::Map) -> Settings { let mut all = serde_json::to_value(settings).unwrap_or_default(); if let serde_json::Value::Object(all) = &mut all { for (key, value) in values { all.insert(key.clone(), value.clone()); } } let mut merged: Settings = serde_json::from_value(all).unwrap_or_else(|_| settings.clone()); merged.normalize(); merged } /// The fields that differ between `before` and `after`, with their new values. pub fn changed_values(before: &Settings, after: &Settings) -> serde_json::Map { let (serde_json::Value::Object(before), serde_json::Value::Object(after)) = (serde_json::to_value(before).unwrap_or_default(), serde_json::to_value(after).unwrap_or_default()) else { return Default::default(); }; after.into_iter().filter(|(key, value)| before.get(key) != Some(value)).collect() } 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 { 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(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) } /// Reads one value as a project sees it, with its overrides. pub fn get_for(project: &Path, read: impl FnOnce(&Settings) -> T) -> T { let mut cache = CACHE.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); let settings = cache.get_or_insert_with(read_file); match settings.project_overrides.contains_key(&project.display().to_string()) { true => read(&settings.for_project(project)), false => read(settings), } } /// Applies a change to this environment's settings, from the settings view /// or a connected device, and writes them back. pub fn apply(patch: &SettingsPatch) { update(|settings| settings.apply_patch(patch)); } /// 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 and model preferences, agents, usage hubs, /// remote connections and project overrides 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), project_overrides: std::mem::take(&mut settings.project_overrides), ..Settings::default() }; *settings = kept; }); } // ---- secrets --------------------------------------------------------------- /// Where sensitive environment variables are kept: next to the settings, /// readable by this user only. fn secrets_path() -> Option { Some(path()?.with_file_name("secrets.json")) } fn secret_key(instance: &str, var: &str) -> String { format!("{instance}/{var}") } fn read_secrets() -> std::collections::BTreeMap { 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) { 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 { 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 patches_keep_to_environment_fields() { let mut settings = parse("{}"); let patch: super::SettingsPatch = serde_json::from_str( r#"{"values": {"workspace": "worktree", "remote_key": "stolen", "chat_width": "wide"}, "projects": {"/src/app": {"submodules": "skip", "worktrees_dir": "/tmp"}}}"#, ) .unwrap(); settings.apply_patch(&patch); assert_eq!(settings.workspace, super::WorkspaceMode::Worktree); assert!(settings.remote_key.is_empty()); assert_eq!(settings.chat_width, super::ChatWidth::Comfortable); let project = std::path::Path::new("/src/app"); assert_eq!(settings.for_project(project).submodules, super::Submodules::Skip); assert_eq!(settings.for_project(project).worktrees_dir, ""); assert_eq!(settings.submodules, super::Submodules::Recursive); // Null drops an override, and a project without any is forgotten. let inherit: super::SettingsPatch = serde_json::from_str(r#"{"projects": {"/src/app": {"submodules": null}}}"#).unwrap(); settings.apply_patch(&inherit); assert!(settings.project_overrides.is_empty()); let values = settings.environment_values(); assert!(values.contains_key("workspace") && values.contains_key("project_overrides")); assert!(!values.contains_key("remote_key") && !values.contains_key("theme_mode")); } #[test] fn scoped_keys_are_fields() { let all = serde_json::to_value(parse("{}")).unwrap(); for key in super::ENVIRONMENT_KEYS { assert!(all.get(key).is_some(), "{key} isn't a setting"); } for key in super::PROJECT_KEYS { assert!(super::ENVIRONMENT_KEYS.contains(key), "{key} isn't an environment setting"); } } #[test] fn changes_are_named_by_field() { let before = parse("{}"); let mut after = before.clone(); after.inactive_days = 9; after.cleanup_inactive_worktrees = Some(4); let changed = super::changed_values(&before, &after); assert_eq!(changed.len(), 2); assert_eq!(changed["inactive_days"], 9); assert_eq!(super::with_values(&before, &changed).cleanup_inactive_worktrees, Some(4)); } #[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"]); } }