//! Preferences that outlive the app, kept in
//! `$XDG_CONFIG_HOME/vectrum-code/settings.json` and edited in the settings
//! view. They're read once and cached, so rendering can consult them freely.
use std::{path::PathBuf, sync::Mutex};
use serde::{Deserialize, Serialize};
use crate::models::Provider;
/// Where a new thread starts: in the project folder, or in a git worktree of
/// its own on a fresh branch.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceMode {
#[default]
Local,
Worktree,
}
/// How a new worktree fills in git submodules.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Submodules {
Skip,
TopLevel,
#[default]
Recursive,
}
/// How a new worktree's branch is named from the thread's first message.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BranchNaming {
/// `branch_prefix` plus a few words the text model picks: `vectrum/add-search`.
#[default]
Static,
/// The model picks a prefix that describes the work: `feat/add-search`.
Semantic,
/// `branch_instructions` tell the model the whole name.
Custom,
}
/// What Ctrl+Q takes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum QuitShortcut {
/// One press quits.
Press,
/// Holding the keys, or pressing them twice quickly, quits.
#[default]
Hold,
}
/// How projects are ordered in the sidebar and the project menus.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProjectOrder {
/// The project with the most recent message first.
#[default]
LastMessage,
Name,
Added,
/// The order projects were added in, as the legacy sidebar's Manual sort.
Manual,
}
/// Which projects share a row in the legacy sidebar.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProjectGrouping {
/// Projects from the same repository share one row.
#[default]
Repository,
/// Projects group only when both the repository and the path inside it match.
RepositoryPath,
/// Every project path gets its own row.
Separate,
}
impl ProjectGrouping {
pub const ALL: [ProjectGrouping; 3] =
[ProjectGrouping::Repository, ProjectGrouping::RepositoryPath, ProjectGrouping::Separate];
pub fn label(self) -> &'static str {
match self {
ProjectGrouping::Repository => "Group by repository",
ProjectGrouping::RepositoryPath => "Group by repository path",
ProjectGrouping::Separate => "Keep separate",
}
}
pub fn description(self) -> &'static str {
match self {
ProjectGrouping::Repository => "Projects from the same repository share one sidebar row.",
ProjectGrouping::RepositoryPath => {
"Projects group only when both the repository and repo-relative path match."
}
ProjectGrouping::Separate => "Every project path gets its own sidebar row.",
}
}
}
/// How the legacy sidebar orders the threads under each project.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ThreadOrder {
/// The thread with the most recent message first.
#[default]
LastMessage,
Created,
}
/// Which of the two chosen themes shows.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ThemeMode {
Light,
Dark,
/// Whichever the desktop is using.
#[default]
System,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TimeFormat {
/// Whatever the locale prefers.
#[default]
System,
Hour12,
Hour24,
}
/// How an answer appears while the agent writes it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Streaming {
#[default]
Tokens,
/// Each paragraph or code block once it's complete.
Paragraphs,
}
/// What a message sent while the agent works does.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FollowUp {
/// Hand it to the agent at once, so it can change course mid-turn.
#[default]
Steer,
/// Hold it until the turn ends.
Queue,
}
/// How eagerly the app checks on things in the background.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BackgroundActivity {
Always,
/// Pauses while the window has been in the background for a while, or
/// the machine saves power.
#[default]
Balanced,
/// Pauses whenever the window is in the background.
Minimal,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct EnvVar {
pub key: String,
pub value: String,
}
/// One configured agent: Claude Code or Pi, with its own binary, home
/// folder, arguments and environment. There can be several of a kind, say
/// two Claude accounts.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ProviderInstance {
/// Stable id, used in model keys (`claude:opus`). The first instance of a
/// kind is named after it.
pub id: String,
pub kind: Provider,
/// Empty shows the kind's name.
pub name: String,
pub enabled: bool,
/// Empty looks the binary up on PATH.
pub binary: String,
/// `CLAUDE_CONFIG_DIR` or `PI_CODING_AGENT_DIR`; empty is the default.
pub home: String,
/// Extra arguments, split like a shell would.
pub args: String,
pub env: Vec<EnvVar>,
}
impl Default for ProviderInstance {
fn default() -> Self {
Self::new(Provider::Claude, "claude")
}
}
impl ProviderInstance {
pub fn new(kind: Provider, id: &str) -> Self {
Self {
id: id.to_string(),
kind,
name: String::new(),
enabled: true,
binary: String::new(),
home: String::new(),
args: String::new(),
env: Vec::new(),
}
}
pub fn label(&self) -> String {
if self.name.trim().is_empty() { self.kind.label().to_string() } else { self.name.trim().to_string() }
}
}
/// Another device's app this one connects to.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct RemoteHost {
pub id: String,
pub name: String,
/// `host` or `host:port`.
pub address: String,
pub key: String,
pub enabled: bool,
}
impl RemoteHost {
pub fn label(&self) -> String {
if self.name.trim().is_empty() { self.address.clone() } else { self.name.trim().to_string() }
}
}
/// A usage hub the Usage page also reads, such as a CLIProxyAPI server.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct UsageHub {
pub name: String,
/// `cliproxy`.
pub kind: String,
pub url: String,
/// The hub's management key.
pub key: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
pub theme_mode: ThemeMode,
/// The theme light mode uses, by id (`bone`, `catppuccin-latte`).
pub light_theme: String,
/// The theme dark mode uses, by id (`graphite`, `tokyo-night`).
pub dark_theme: String,
/// Model keys (`claude:opus`, `pi:anthropic/claude-sonnet-5-5`), in the
/// order they were starred.
pub favorites: Vec<String>,
// ---- new threads ----
/// The model key new threads start on. `None` follows the open thread.
pub default_model: Option<String>,
/// `default`, an effort level (`high`), `ultracode` or `ultrathink`.
/// `None` follows the open thread.
pub default_reasoning: Option<String>,
/// A permission mode as the CLI spells it. `None` follows the open thread.
pub default_access: Option<String>,
pub workspace: WorkspaceMode,
pub submodules: Submodules,
/// Base new worktrees on the branch as origin has it.
pub start_from_origin: bool,
/// How new worktree branches are named.
pub branch_naming: BranchNaming,
/// The prefix static branch names start with; empty for none.
pub branch_prefix: String,
/// What custom branch naming tells the model.
pub branch_instructions: String,
/// Where new worktrees go; empty is the app's data folder.
pub worktrees_dir: String,
/// Keep the branch and worktree strip under the composer once a thread
/// has started, not only on new threads.
pub composer_context: bool,
/// Claude's plan mode as a Build/Plan toggle in the composer, with
/// Shift+Tab to switch.
pub plan_mode: bool,
/// The ring showing how full the context window is.
pub context_meter: bool,
/// Where the Add project browser opens; empty is the home folder.
pub add_project_dir: String,
// ---- text generation ----
/// The model key thread titles are written with; empty keeps the first
/// line of the first message.
pub text_model: String,
/// The reasoning level for generated text, as [`Settings::default_reasoning`].
pub text_reasoning: Option<String>,
// ---- conversation ----
/// Fold a finished turn's tool calls into one "Worked for" row.
pub fold_turns: bool,
/// Show what threads and turns cost.
pub show_costs: bool,
/// Send with Ctrl+Enter, so Enter starts a new line.
pub send_with_ctrl_enter: bool,
pub follow_up: FollowUp,
pub streaming: Streaming,
/// List skills in the `/` menu too; `$` always lists them.
pub slash_skills: bool,
/// Highlight Markdown in the composer as you type.
pub rich_composer: bool,
/// Rest the composer to one line while scrolling a conversation.
pub collapse_composer: bool,
pub time_format: TimeFormat,
/// Open the diff after a turn that changed enough.
pub proactive_panels: bool,
/// Ignore whitespace-only edits in diffs.
pub hide_whitespace: bool,
/// Open diffs with every file folded.
pub diff_collapsed: bool,
pub diff_side_by_side: bool,
// ---- sidebar ----
/// T3 Code's legacy sidebar: threads in per-project trees instead of the
/// default flat inbox.
pub legacy_sidebar: bool,
/// How the legacy sidebar orders threads under a project.
pub thread_order: ThreadOrder,
/// How many threads the legacy sidebar shows per project before "Show more".
pub visible_threads: u32,
/// Which projects share a row in the legacy sidebar.
pub project_grouping: ProjectGrouping,
/// Per-project grouping rules, by project path, overriding the default.
pub project_grouping_overrides: std::collections::BTreeMap<String, ProjectGrouping>,
/// One-line thread cards in the sidebar.
pub compact_threads: bool,
/// Group sidebar threads under their project: turns on the legacy
/// sidebar, like `legacy_sidebar`.
pub group_by_project: bool,
pub project_order: ProjectOrder,
/// Fold working threads into their own section until they need you.
pub working_section: bool,
/// Continue a thread stopped by a usage limit once the limit resets.
pub auto_resume_limited: bool,
/// Snooze a thread stopped by a usage limit until the limit resets.
pub snooze_limited: bool,
/// Settle a worktree thread once its pull request merges.
pub auto_settle_merged: bool,
pub auto_settle_inactive: bool,
pub inactive_days: u32,
// ---- confirmations ----
pub confirm_unpin: bool,
pub confirm_archive: bool,
/// Settling from a thread card takes a second click.
pub confirm_settle: bool,
pub confirm_delete: bool,
pub quit_shortcut: QuitShortcut,
// ---- notifications and background work ----
/// A desktop notification when a thread finishes or needs you while the
/// window is in the background.
pub notify: bool,
/// Play a sound with each desktop notification.
pub notify_sound: bool,
/// A toast when another thread finishes or needs you while the window
/// has focus.
pub in_app_notifications: bool,
/// Resume threads a restart or crash interrupted.
pub continue_after_restart: bool,
/// Check installed agents for newer versions.
pub update_checks: bool,
pub background_activity: BackgroundActivity,
/// Seconds between provider health checks; 0 turns them off.
pub health_check_secs: u64,
// ---- integrations ----
/// The editor "Open in" uses, by name (`Zed`).
pub editor: Option<String>,
pub providers: Vec<ProviderInstance>,
pub usage_hubs: Vec<UsageHub>,
// ---- remote connections ----
/// Let other devices' apps connect to this one.
pub remote_server: bool,
pub remote_port: u16,
/// The key other devices connect with; made when first needed.
pub remote_key: String,
/// Other devices' apps this one connects to.
pub remotes: Vec<RemoteHost>,
/// Superseded by `providers`; read once to carry an old path override over.
#[serde(skip_serializing)]
claude_bin: Option<String>,
#[serde(skip_serializing)]
pi_bin: Option<String>,
/// Superseded by the theme fields: `bone`, `auto` or `graphite`.
#[serde(skip_serializing)]
colorway: Option<String>,
}
impl Default for Settings {
fn default() -> Self {
Self {
theme_mode: ThemeMode::System,
light_theme: "bone".into(),
dark_theme: "graphite".into(),
favorites: Vec::new(),
default_model: None,
default_reasoning: None,
default_access: None,
workspace: WorkspaceMode::Local,
submodules: Submodules::Recursive,
start_from_origin: false,
branch_naming: BranchNaming::Static,
branch_prefix: "vectrum".into(),
branch_instructions: String::new(),
worktrees_dir: String::new(),
composer_context: false,
plan_mode: false,
context_meter: false,
add_project_dir: String::new(),
text_model: "claude:haiku".into(),
text_reasoning: Some("low".into()),
fold_turns: true,
show_costs: true,
send_with_ctrl_enter: false,
follow_up: FollowUp::Steer,
streaming: Streaming::Tokens,
slash_skills: true,
rich_composer: true,
collapse_composer: false,
time_format: TimeFormat::System,
proactive_panels: false,
hide_whitespace: true,
diff_collapsed: false,
diff_side_by_side: false,
legacy_sidebar: false,
thread_order: ThreadOrder::LastMessage,
visible_threads: 6,
project_grouping: ProjectGrouping::Repository,
project_grouping_overrides: std::collections::BTreeMap::new(),
compact_threads: false,
group_by_project: false,
project_order: ProjectOrder::LastMessage,
working_section: false,
auto_resume_limited: false,
snooze_limited: false,
auto_settle_merged: true,
auto_settle_inactive: false,
inactive_days: 3,
confirm_unpin: false,
confirm_archive: false,
confirm_settle: false,
confirm_delete: true,
quit_shortcut: QuitShortcut::Hold,
notify: false,
notify_sound: false,
in_app_notifications: false,
continue_after_restart: false,
update_checks: false,
background_activity: BackgroundActivity::Balanced,
health_check_secs: 300,
editor: None,
providers: Vec::new(),
usage_hubs: Vec::new(),
remote_server: false,
remote_port: crate::remote::DEFAULT_PORT,
remote_key: String::new(),
remotes: Vec::new(),
claude_bin: None,
pi_bin: None,
colorway: None,
}
}
}
impl Settings {
/// Makes sure there's at least one instance of each kind, carrying old
/// binary overrides over, and turns an old colorway into a theme mode.
fn normalize(&mut self) {
if let Some(colorway) = self.colorway.take() {
self.theme_mode = match colorway.as_str() {
"bone" => ThemeMode::Light,
"graphite" => ThemeMode::Dark,
_ => ThemeMode::System,
};
}
for (kind, old_bin) in [(Provider::Claude, self.claude_bin.take()), (Provider::Pi, self.pi_bin.take())] {
if !self.providers.iter().any(|instance| instance.kind == kind) {
let mut instance = ProviderInstance::new(kind, kind.key());
instance.binary = old_bin.unwrap_or_default();
self.providers.push(instance);
}
}
}
pub fn instance(&self, id: &str) -> Option<&ProviderInstance> {
self.providers.iter().find(|instance| instance.id == id)
}
}
static CACHE: Mutex<Option<Settings>> = Mutex::new(None);
pub fn path() -> Option<PathBuf> {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?;
Some(base.join("vectrum-code").join("settings.json"))
}
fn read_file() -> Settings {
parse(&path().and_then(|path| std::fs::read_to_string(path).ok()).unwrap_or_default())
}
/// Reads a settings file's text, falling back to the defaults.
fn parse(text: &str) -> Settings {
let mut settings: Settings = serde_json::from_str(text).unwrap_or_default();
settings.normalize();
settings
}
/// Reads one value out of the settings.
pub fn get<T>(read: impl FnOnce(&Settings) -> T) -> T {
let mut cache = CACHE.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
read(cache.get_or_insert_with(read_file))
}
pub fn load() -> Settings {
get(Settings::clone)
}
/// The configured agents, enabled or not.
pub fn instances() -> Vec<ProviderInstance> {
get(|settings| settings.providers.clone())
}
/// One agent's configuration, falling back to a default for its kind.
pub fn instance(id: &str, kind: Provider) -> ProviderInstance {
get(|settings| settings.instance(id).cloned()).unwrap_or_else(|| ProviderInstance::new(kind, id))
}
/// Applies `edit` to the settings and writes them back.
pub fn update(edit: impl FnOnce(&mut Settings)) {
let settings = {
let mut cache = CACHE.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
let settings = cache.get_or_insert_with(read_file);
edit(settings);
settings.normalize();
settings.clone()
};
let Some(path) = path() else { return };
let written = path
.parent()
.map(std::fs::create_dir_all)
.unwrap_or(Ok(()))
.and_then(|_| std::fs::write(&path, serde_json::to_string_pretty(&settings).unwrap_or_default()));
if let Err(err) = written {
eprintln!("vectrum-code: couldn't save {}: {err}", path.display());
}
}
/// Puts everything but favorites, agents, usage hubs and remote connections
/// back to its default.
pub fn restore_defaults() {
update(|settings| {
let kept = Settings {
favorites: std::mem::take(&mut settings.favorites),
providers: std::mem::take(&mut settings.providers),
usage_hubs: std::mem::take(&mut settings.usage_hubs),
remote_server: settings.remote_server,
remote_port: settings.remote_port,
remote_key: std::mem::take(&mut settings.remote_key),
remotes: std::mem::take(&mut settings.remotes),
..Settings::default()
};
*settings = kept;
});
}
/// `~/bin/claude` → `/home/me/bin/claude`.
pub fn expand_home(path: &str) -> PathBuf {
match (path.strip_prefix("~/"), std::env::var_os("HOME")) {
(Some(rest), Some(home)) => PathBuf::from(home).join(rest),
_ if path == "~" => std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(path)),
_ => PathBuf::from(path),
}
}
/// Where the app keeps its own data: threads, worktrees, scratch space.
pub fn data_dir() -> Option<PathBuf> {
let base = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")))?;
Some(base.join("vectrum-code"))
}
#[cfg(test)]
mod tests {
use super::{ThemeMode, parse};
#[test]
fn old_colorways_become_theme_modes() {
for (colorway, mode) in [("bone", ThemeMode::Light), ("graphite", ThemeMode::Dark), ("auto", ThemeMode::System)] {
let settings = parse(&format!(r#"{{"colorway": "{colorway}"}}"#));
assert_eq!(settings.theme_mode, mode);
assert_eq!(settings.light_theme, "bone");
assert_eq!(settings.dark_theme, "graphite");
assert!(!serde_json::to_string(&settings).unwrap().contains("colorway"));
}
assert_eq!(parse("{}").theme_mode, ThemeMode::System);
let saved = parse(r#"{"theme_mode": "dark", "dark_theme": "nord"}"#);
assert_eq!((saved.theme_mode, saved.dark_theme.as_str()), (ThemeMode::Dark, "nord"));
}
}