//! 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,
}
/// 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,
}
/// 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,
/// 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<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(),
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,
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"]);
}
}