Vectrum
Sign In

Add the settings T3 Code has that the app lacked

New settings, with T3 Code's defaults: a three-way send shortcut (Enter,
Ctrl+Enter for multiline prompts, Ctrl+Enter always), responses that
wait for the whole turn, a Double press quit shortcut, diff colors (red
and green, or blue and orange), chat width, the legacy per-project
sidebar, project grouping, agent browser access, hidden models and model
order, and per-instance accent colors, Claude auto-compact thresholds,
custom models and sensitive environment variables kept in a secrets file
only the user can read.

Older settings files carry over: Ctrl+Enter sending becomes the matching
send shortcut and project grouping becomes the legacy sidebar. Several
defaults follow T3 Code now (queue follow-ups, show finished paragraphs,
collapse the composer on scroll, collapsed diff files, start worktrees
from origin, check for provider updates); saved choices are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent 77537cccommit8ee3c77

Showing 7 changed files with 423 additions and 35 deletions.

+22−5src/claude.rsView File
@@ -328,10 +328,20 @@ fn claude_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> {
328328 if let Some(session_id) = &config.resume {
329329 command.args(["--resume", session_id]);
330330 }
331+ if let Some(window) = auto_compact_window(&config.instance.auto_compact) {
332+ command.args(["--settings", &format!(r#"{{"autoCompactWindow":{window}}}"#)]);
333+ }
331334 command.args(split_args(&config.instance.args));
332335 Ok((binary, command))
333336 }
334337
338+ /// The "Auto-compact after" field as a token count, if it's one Claude
339+ /// takes: 100,000 to 1,000,000.
340+ pub fn auto_compact_window(text: &str) -> Option<u64> {
341+ let digits: String = text.chars().filter(|c| !matches!(c, ',' | '_' | ' ')).collect();
342+ digits.parse::<u64>().ok().filter(|tokens| (100_000..=1_000_000).contains(tokens))
343+ }
344+
335345 fn pi_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> {
336346 let (binary, mut command) = base_command(&config.instance)?;
337347 command.args(["--mode", "rpc"]).current_dir(&config.cwd);
@@ -403,10 +413,8 @@ pub fn base_command(instance: &ProviderInstance) -> Result<(PathBuf, Command)> {
403413 }
404414 command.env(home_var(instance.kind), home);
405415 }
406- for var in &instance.env {
407- if !var.key.trim().is_empty() {
408- command.env(var.key.trim(), &var.value);
409- }
416+ for (key, value) in instance.env_vars() {
417+ command.env(key, value);
410418 }
411419 // Pi is a Node script; a desktop launcher's PATH may not reach the Node it
412420 // was installed with, which usually sits next to it.
@@ -508,7 +516,16 @@ pub fn is_executable(path: &Path) -> bool {
508516
509517 #[cfg(test)]
510518 mod tests {
511- use super::split_args;
519+ use super::{auto_compact_window, split_args};
520+
521+ #[::core::prelude::v1::test]
522+ fn auto_compact_takes_claudes_range() {
523+ assert_eq!(auto_compact_window("300000"), Some(300_000));
524+ assert_eq!(auto_compact_window("1,000,000"), Some(1_000_000));
525+ assert_eq!(auto_compact_window("99999"), None);
526+ assert_eq!(auto_compact_window("2000000"), None);
527+ assert_eq!(auto_compact_window(""), None);
528+ }
512529
513530 #[::core::prelude::v1::test]
514531 fn splits_like_a_shell() {
+63−4src/models.rsView File
@@ -131,7 +131,11 @@ pub struct InstanceState {
131131 pub status: ProbeStatus,
132132 /// A check is running; the fields keep the previous one's findings.
133133 pub checking: bool,
134+ /// The models the agent offers plus the custom ones, in the order the
135+ /// settings put them.
134136 pub models: Vec<ModelEntry>,
137+ /// The models as the agent reported them.
138+ reported: Vec<ModelEntry>,
135139 /// Claude's `default` entry, which the picker shows as the model it resolves to.
136140 pub default: Option<ModelEntry>,
137141 /// The signed-in account (Claude's email).
@@ -154,6 +158,7 @@ impl InstanceState {
154158 config,
155159 checking: false,
156160 models: Vec::new(),
161+ reported: Vec::new(),
157162 default: None,
158163 account: None,
159164 plan: None,
@@ -184,6 +189,44 @@ impl InstanceState {
184189 _ => false,
185190 }
186191 }
192+
193+ /// Whether a model was added by hand rather than reported by the agent.
194+ pub fn is_custom(&self, model: &ModelEntry) -> bool {
195+ self.config.custom_models.iter().any(|custom| custom.slug == model.id)
196+ && !self.reported.iter().any(|reported| reported.id == model.id)
197+ }
198+
199+ /// Takes the agent's model list, adding the custom models and putting
200+ /// them in the settings' order.
201+ fn set_reported(&mut self, models: Vec<ModelEntry>) {
202+ self.reported = models;
203+ self.arrange();
204+ }
205+
206+ fn arrange(&mut self) {
207+ let mut models = self.reported.clone();
208+ for custom in &self.config.custom_models {
209+ if custom.slug.trim().is_empty() || models.iter().any(|model| model.id == custom.slug) {
210+ continue;
211+ }
212+ models.push(ModelEntry {
213+ provider: self.config.kind,
214+ instance: self.config.id.clone(),
215+ id: custom.slug.clone(),
216+ resolved: custom.slug.clone(),
217+ name: if custom.name.trim().is_empty() { custom.slug.clone() } else { custom.name.trim().to_string() },
218+ detail: "Custom".into(),
219+ legacy: false,
220+ efforts: Vec::new(),
221+ fast_mode: false,
222+ auto_mode: false,
223+ context_window: None,
224+ });
225+ }
226+ let order = settings::get(|settings| settings.model_order.clone());
227+ models.sort_by_key(|model| order.iter().position(|key| *key == model.key()).unwrap_or(usize::MAX));
228+ self.models = models;
229+ }
187230 }
188231
189232 pub struct Catalog {
@@ -233,6 +276,7 @@ impl Catalog {
233276 if state.config != config {
234277 let enabled = config.enabled;
235278 state.config = config;
279+ state.arrange();
236280 if !enabled {
237281 state.status = ProbeStatus::Disabled;
238282 }
@@ -308,7 +352,7 @@ impl Catalog {
308352 Ok(probe) => {
309353 state.status = ProbeStatus::Ready;
310354 if !probe.models.is_empty() || state.config.kind != Provider::Claude {
311- state.models = probe.models;
355+ state.set_reported(probe.models);
312356 state.default = probe.default;
313357 }
314358 state.account = probe.account.or(state.account.take());
@@ -326,7 +370,7 @@ impl Catalog {
326370 state.status = status;
327371 }
328372 if state.config.kind != Provider::Claude {
329- state.models.clear();
373+ state.set_reported(Vec::new());
330374 }
331375 }
332376 }
@@ -368,8 +412,8 @@ impl Catalog {
368412 state.plan = probe.plan;
369413 changed = true;
370414 }
371- if !probe.models.is_empty() && state.models != probe.models {
372- state.models = probe.models;
415+ if !probe.models.is_empty() && state.reported != probe.models {
416+ state.set_reported(probe.models);
373417 state.default = probe.default;
374418 changed = true;
375419 }
@@ -413,6 +457,21 @@ impl Catalog {
413457 self.instance(instance).map(|state| state.commands.as_slice()).unwrap_or_default()
414458 }
415459
460+ /// Puts every instance's models in the order the settings give them,
461+ /// after that order changed.
462+ pub fn rearrange(cx: &mut App) {
463+ for state in &mut cx.global_mut::<Catalog>().instances {
464+ state.arrange();
465+ }
466+ cx.refresh_windows();
467+ }
468+
469+ /// Whether the settings hide a model from the model picker.
470+ pub fn is_hidden(&self, model: &ModelEntry) -> bool {
471+ let key = model.key();
472+ settings::get(|settings| settings.hidden_models.contains(&key))
473+ }
474+
416475 pub fn is_favorite(&self, model: &ModelEntry) -> bool {
417476 self.favorites.contains(&model.key())
418477 }
+17−5src/session.rsView File
@@ -617,7 +617,7 @@ impl Session {
617617 permission_mode: self.permission_mode,
618618 effort: self.reasoning.effort(),
619619 resume: resume.clone(),
620- browser_session: Some(browser::session_name(&self.id)),
620+ browser_session: settings::get(|settings| settings.agent_browser).then(|| browser::session_name(&self.id)),
621621 };
622622 let process = match AgentProcess::spawn(&config, tx) {
623623 Ok(process) => process,
@@ -657,6 +657,7 @@ impl Session {
657657 Provider::Jcode => {
658658 self.jcode = JcodeLink {
659659 browser_note: browser::find_binary()
660+ .filter(|_| config.browser_session.is_some())
660661 .map(|binary| browser::jcode_prompt(&browser::session_name(&self.id), &binary)),
661662 ..JcodeLink::default()
662663 };
@@ -2546,15 +2547,26 @@ impl Session {
25462547 /// Adds streamed text to an answer. With "Show finished paragraphs" on,
25472548 /// the view only takes whole paragraphs and code blocks.
25482549 fn append_text(&mut self, ix: usize, delta: &str, cx: &mut Context<Self>) {
2549- let paragraphs = settings::get(|settings| settings.streaming == Streaming::Paragraphs);
2550+ let streaming = settings::get(|settings| settings.streaming);
25502551 let Some(ChatItem::Assistant(answer)) = self.items.get_mut(ix) else { return };
25512552 answer.text.push_str(delta);
2552- let target = if paragraphs { finished_prefix(&answer.text) } else { answer.text.len() };
2553+ let target = match streaming {
2554+ Streaming::Tokens => answer.text.len(),
2555+ Streaming::Paragraphs => finished_prefix(&answer.text),
2556+ Streaming::Turn => return,
2557+ };
25532558 answer.show(target, cx);
25542559 }
25552560
2556- /// Shows the rest of an answer's text.
2561+ /// Shows the rest of an answer's text once its message is complete,
2562+ /// unless answers wait for the end of the turn.
25572563 fn flush_item(&mut self, ix: usize, cx: &mut Context<Self>) {
2564+ if settings::get(|settings| settings.streaming) != Streaming::Turn {
2565+ self.show_all(ix, cx);
2566+ }
2567+ }
2568+
2569+ fn show_all(&mut self, ix: usize, cx: &mut Context<Self>) {
25582570 if let Some(ChatItem::Assistant(answer)) = self.items.get_mut(ix) {
25592571 let end = answer.text.len();
25602572 answer.show(end, cx);
@@ -2563,7 +2575,7 @@ impl Session {
25632575
25642576 fn flush_text(&mut self, cx: &mut Context<Self>) {
25652577 for ix in 0..self.items.len() {
2566- self.flush_item(ix, cx);
2578+ self.show_all(ix, cx);
25672579 }
25682580 }
25692581
+285−15src/settings.rsView File
@@ -45,11 +45,47 @@ pub enum BranchNaming {
4545 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
4646 #[serde(rename_all = "snake_case")]
4747 pub enum QuitShortcut {
48- /// One press quits.
48+ /// One press quits ("Direct").
4949 Press,
5050 /// Holding the keys, or pressing them twice quickly, quits.
5151 #[default]
5252 Hold,
53+ /// Two quick presses quit.
54+ Double,
55+ }
56+
57+ /// Which keys send a prompt from the composer.
58+ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
59+ #[serde(rename_all = "snake_case")]
60+ pub enum SendShortcut {
61+ /// Enter sends, Shift+Enter starts a new line.
62+ #[default]
63+ Enter,
64+ /// Enter sends a one-line prompt; once the prompt has several lines,
65+ /// Enter starts a new line and Ctrl+Enter sends.
66+ ModEnterMultiline,
67+ /// Ctrl+Enter sends, Enter starts a new line.
68+ ModEnter,
69+ }
70+
71+ /// The colors diffs and change counts use for additions and deletions.
72+ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
73+ #[serde(rename_all = "snake_case")]
74+ pub enum DiffColors {
75+ #[default]
76+ RedGreen,
77+ /// Easier to tell apart with red-green color blindness.
78+ BlueOrange,
79+ }
80+
81+ /// How wide the conversation and the composer may grow on large screens.
82+ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
83+ #[serde(rename_all = "snake_case")]
84+ pub enum ChatWidth {
85+ #[default]
86+ Comfortable,
87+ Wide,
88+ Full,
5389 }
5490
5591 /// How projects are ordered in the sidebar and the project menus.
@@ -88,10 +124,13 @@ pub enum TimeFormat {
88124 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
89125 #[serde(rename_all = "snake_case")]
90126 pub enum Streaming {
91- #[default]
127+ /// Every token as it arrives.
92128 Tokens,
93129 /// Each paragraph or code block once it's complete.
130+ #[default]
94131 Paragraphs,
132+ /// The whole answer once the turn ends.
133+ Turn,
95134 }
96135
97136 /// What a message sent while the agent works does.
@@ -99,9 +138,9 @@ pub enum Streaming {
99138 #[serde(rename_all = "snake_case")]
100139 pub enum FollowUp {
101140 /// Hand it to the agent at once, so it can change course mid-turn.
102- #[default]
103141 Steer,
104142 /// Hold it until the turn ends.
143+ #[default]
105144 Queue,
106145 }
107146
@@ -122,7 +161,22 @@ pub enum BackgroundActivity {
122161 #[serde(default)]
123162 pub struct EnvVar {
124163 pub key: String,
164+ /// Empty for a sensitive variable: its value lives in the secrets file.
125165 pub value: String,
166+ /// Kept out of `settings.json`, in a file only this user can read, and
167+ /// never shown again once saved.
168+ pub sensitive: bool,
169+ }
170+
171+ /// A model added by hand to an instance's list, for models the agent
172+ /// doesn't report.
173+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
174+ #[serde(default)]
175+ pub struct CustomModel {
176+ /// What the agent is launched with: `claude-sonnet-5`, `anthropic/claude-sonnet-5`.
177+ pub slug: String,
178+ /// Empty shows the slug.
179+ pub name: String,
126180 }
127181
128182 /// One configured agent: Claude Code or Pi, with its own binary, home
@@ -145,6 +199,13 @@ pub struct ProviderInstance {
145199 /// Extra arguments, split like a shell would.
146200 pub args: String,
147201 pub env: Vec<EnvVar>,
202+ /// A hex color (`#3e9bfa`) marking the instance in lists and pickers;
203+ /// empty for none.
204+ pub accent: String,
205+ /// Claude: compact the conversation after this many tokens (100,000 to
206+ /// 1,000,000); empty keeps Claude's default.
207+ pub auto_compact: String,
208+ pub custom_models: Vec<CustomModel>,
148209 }
149210
150211 impl Default for ProviderInstance {
@@ -164,12 +225,33 @@ impl ProviderInstance {
164225 home: String::new(),
165226 args: String::new(),
166227 env: Vec::new(),
228+ accent: String::new(),
229+ auto_compact: String::new(),
230+ custom_models: Vec::new(),
167231 }
168232 }
169233
170234 pub fn label(&self) -> String {
171235 if self.name.trim().is_empty() { self.kind.label().to_string() } else { self.name.trim().to_string() }
172236 }
237+
238+ /// The variables the agent runs with, sensitive values read from the
239+ /// secrets file.
240+ pub fn env_vars(&self) -> Vec<(String, String)> {
241+ let secrets = if self.env.iter().any(|var| var.sensitive) { read_secrets() } else { Default::default() };
242+ self.env
243+ .iter()
244+ .filter(|var| !var.key.trim().is_empty())
245+ .map(|var| {
246+ let key = var.key.trim().to_string();
247+ let value = match var.sensitive {
248+ true => secrets.get(&secret_key(&self.id, &key)).cloned().unwrap_or_default(),
249+ false => var.value.clone(),
250+ };
251+ (key, value)
252+ })
253+ .collect()
254+ }
173255 }
174256
175257 /// Another device's app this one connects to.
@@ -213,6 +295,11 @@ pub struct Settings {
213295 /// Model keys (`claude:opus`, `pi:anthropic/claude-sonnet-5-5`), in the
214296 /// order they were starred.
215297 pub favorites: Vec<String>,
298+ /// Model keys the model picker leaves out.
299+ pub hidden_models: Vec<String>,
300+ /// Model keys in the order the picker lists them; models not in it
301+ /// follow in the agent's order.
302+ pub model_order: Vec<String>,
216303
217304 // ---- new threads ----
218305 /// The model key new threads start on. `None` follows the open thread.
@@ -257,7 +344,9 @@ pub struct Settings {
257344 pub fold_turns: bool,
258345 /// Show what threads and turns cost.
259346 pub show_costs: bool,
260- /// Send with Ctrl+Enter, so Enter starts a new line.
347+ pub send_shortcut: SendShortcut,
348+ /// Send with Ctrl+Enter, so Enter starts a new line. Follows
349+ /// `send_shortcut` (true for [`SendShortcut::ModEnter`]).
261350 pub send_with_ctrl_enter: bool,
262351 pub follow_up: FollowUp,
263352 pub streaming: Streaming,
@@ -276,11 +365,20 @@ pub struct Settings {
276365 pub diff_collapsed: bool,
277366 pub diff_side_by_side: bool,
278367
368+ // ---- appearance ----
369+ pub diff_colors: DiffColors,
370+ pub chat_width: ChatWidth,
371+
279372 // ---- sidebar ----
280373 /// One-line thread cards in the sidebar.
281374 pub compact_threads: bool,
282- /// Group sidebar threads under their project.
375+ /// Per-project thread trees instead of the flat thread list.
376+ pub legacy_sidebar: bool,
377+ /// Group sidebar threads under their project. Follows `legacy_sidebar`.
283378 pub group_by_project: bool,
379+ /// Show a project open on several devices (this one and remote
380+ /// connections) as one project when they're the same repository.
381+ pub project_grouping: bool,
284382 pub project_order: ProjectOrder,
285383 /// Fold working threads into their own section until they need you.
286384 pub working_section: bool,
@@ -318,6 +416,8 @@ pub struct Settings {
318416 pub health_check_secs: u64,
319417
320418 // ---- integrations ----
419+ /// Let agents use the browser panel's shared browser.
420+ pub agent_browser: bool,
321421 /// The editor "Open in" uses, by name (`Zed`).
322422 pub editor: Option<String>,
323423 pub providers: Vec<ProviderInstance>,
@@ -349,12 +449,14 @@ impl Default for Settings {
349449 light_theme: "bone".into(),
350450 dark_theme: "graphite".into(),
351451 favorites: Vec::new(),
452+ hidden_models: Vec::new(),
453+ model_order: Vec::new(),
352454 default_model: None,
353455 default_reasoning: None,
354456 default_access: None,
355457 workspace: WorkspaceMode::Local,
356458 submodules: Submodules::Recursive,
357- start_from_origin: false,
459+ start_from_origin: true,
358460 branch_naming: BranchNaming::Static,
359461 branch_prefix: "vectrum".into(),
360462 branch_instructions: String::new(),
@@ -367,19 +469,24 @@ impl Default for Settings {
367469 text_reasoning: Some("low".into()),
368470 fold_turns: true,
369471 show_costs: true,
472+ send_shortcut: SendShortcut::Enter,
370473 send_with_ctrl_enter: false,
371- follow_up: FollowUp::Steer,
372- streaming: Streaming::Tokens,
474+ follow_up: FollowUp::Queue,
475+ streaming: Streaming::Paragraphs,
373476 slash_skills: true,
374477 rich_composer: true,
375- collapse_composer: false,
478+ collapse_composer: true,
376479 time_format: TimeFormat::System,
377480 proactive_panels: false,
378481 hide_whitespace: true,
379- diff_collapsed: false,
482+ diff_collapsed: true,
380483 diff_side_by_side: false,
484+ diff_colors: DiffColors::RedGreen,
485+ chat_width: ChatWidth::Comfortable,
381486 compact_threads: false,
487+ legacy_sidebar: false,
382488 group_by_project: false,
489+ project_grouping: true,
383490 project_order: ProjectOrder::LastMessage,
384491 working_section: false,
385492 auto_resume_limited: false,
@@ -395,9 +502,10 @@ impl Default for Settings {
395502 notify_sound: false,
396503 in_app_notifications: false,
397504 continue_after_restart: false,
398- update_checks: false,
505+ update_checks: true,
399506 background_activity: BackgroundActivity::Balanced,
400507 health_check_secs: 300,
508+ agent_browser: true,
401509 editor: None,
402510 providers: Vec::new(),
403511 usage_hubs: Vec::new(),
@@ -430,6 +538,77 @@ impl Settings {
430538 self.providers.push(instance);
431539 }
432540 }
541+ // Fields older code reads, kept in step with the ones replacing them.
542+ self.send_with_ctrl_enter = self.send_shortcut == SendShortcut::ModEnter;
543+ self.group_by_project = self.legacy_sidebar;
544+ }
545+
546+ /// The settings "Restore device defaults" would change, by their
547+ /// labels on the settings pages, in the order T3 Code lists them.
548+ pub fn differing_defaults(&self) -> Vec<&'static str> {
549+ let d = Settings::default();
550+ let theme = (self.theme_mode, &self.light_theme, &self.dark_theme) != (d.theme_mode, &d.light_theme, &d.dark_theme);
551+ [
552+ ("Theme", theme),
553+ ("Diff colors", self.diff_colors != d.diff_colors),
554+ ("Chat width", self.chat_width != d.chat_width),
555+ ("Time format", self.time_format != d.time_format),
556+ ("Thread notifications", (self.notify, self.notify_sound) != (d.notify, d.notify_sound)),
557+ ("In-app notifications", self.in_app_notifications != d.in_app_notifications),
558+ ("Project Grouping", self.project_grouping != d.project_grouping),
559+ ("Project order", self.project_order != d.project_order),
560+ ("Working section", self.working_section != d.working_section),
561+ (
562+ "Auto-settle inactive threads",
563+ (self.auto_settle_inactive, self.inactive_days) != (d.auto_settle_inactive, d.inactive_days),
564+ ),
565+ ("Auto-settle merged threads", self.auto_settle_merged != d.auto_settle_merged),
566+ ("Auto-resume limited threads", self.auto_resume_limited != d.auto_resume_limited),
567+ ("Snooze limited threads", self.snooze_limited != d.snooze_limited),
568+ ("Composer context", self.composer_context != d.composer_context),
569+ ("Thread cards", self.compact_threads != d.compact_threads),
570+ ("Default diff file state", self.diff_collapsed != d.diff_collapsed),
571+ ("Diff whitespace changes", self.hide_whitespace != d.hide_whitespace),
572+ ("Diff layout", self.diff_side_by_side != d.diff_side_by_side),
573+ ("Proactive panels", self.proactive_panels != d.proactive_panels),
574+ ("Show skills in slash menu", self.slash_skills != d.slash_skills),
575+ ("Collapse composer on scroll", self.collapse_composer != d.collapse_composer),
576+ ("Rich text composer", self.rich_composer != d.rich_composer),
577+ ("Send shortcut", self.send_shortcut != d.send_shortcut),
578+ ("Follow-up behavior", self.follow_up != d.follow_up),
579+ ("Context window indicator", self.context_meter != d.context_meter),
580+ ("Plan mode", self.plan_mode != d.plan_mode),
581+ ("Sidebar", self.legacy_sidebar != d.legacy_sidebar),
582+ ("Response streaming", self.streaming != d.streaming),
583+ ("Fold finished turns", self.fold_turns != d.fold_turns),
584+ ("Show costs", self.show_costs != d.show_costs),
585+ ("Provider update checks", self.update_checks != d.update_checks),
586+ ("Continue threads after restarts", self.continue_after_restart != d.continue_after_restart),
587+ ("Background activity", self.background_activity != d.background_activity),
588+ ("Health check interval", self.health_check_secs != d.health_check_secs),
589+ ("Default model", (&self.default_model, &self.default_reasoning) != (&d.default_model, &d.default_reasoning)),
590+ ("Permissions", self.default_access != d.default_access),
591+ ("New thread mode", self.workspace != d.workspace),
592+ ("Submodules", self.submodules != d.submodules),
593+ ("New worktrees start from origin", self.start_from_origin != d.start_from_origin),
594+ ("Add project base directory", self.add_project_dir != d.add_project_dir),
595+ ("Unpin confirmation", self.confirm_unpin != d.confirm_unpin),
596+ ("Archive confirmation", self.confirm_settle != d.confirm_settle),
597+ ("Delete confirmation", self.confirm_delete != d.confirm_delete),
598+ ("Quit shortcut", self.quit_shortcut != d.quit_shortcut),
599+ ("Text generation model", (&self.text_model, &self.text_reasoning) != (&d.text_model, &d.text_reasoning)),
600+ (
601+ "Worktree branch naming",
602+ (self.branch_naming, &self.branch_prefix, &self.branch_instructions)
603+ != (d.branch_naming, &d.branch_prefix, &d.branch_instructions),
604+ ),
605+ ("Worktree location", self.worktrees_dir != d.worktrees_dir),
606+ ("Agent browser access", self.agent_browser != d.agent_browser),
607+ ("Open in", self.editor != d.editor),
608+ ]
609+ .into_iter()
610+ .filter_map(|(label, differs)| differs.then_some(label))
611+ .collect()
433612 }
434613
435614 pub fn instance(&self, id: &str) -> Option<&ProviderInstance> {
@@ -452,7 +631,16 @@ fn read_file() -> Settings {
452631
453632 /// Reads a settings file's text, falling back to the defaults.
454633 fn parse(text: &str) -> Settings {
455- let mut settings: Settings = serde_json::from_str(text).unwrap_or_default();
634+ let raw: serde_json::Value = serde_json::from_str(text).unwrap_or_default();
635+ let mut settings: Settings = serde_json::from_value(raw.clone()).unwrap_or_default();
636+ // Carry settings over from the fields that came before these.
637+ let missing = |key: &str| raw.get(key).is_none();
638+ if missing("send_shortcut") && settings.send_with_ctrl_enter {
639+ settings.send_shortcut = SendShortcut::ModEnter;
640+ }
641+ if missing("legacy_sidebar") {
642+ settings.legacy_sidebar = settings.group_by_project;
643+ }
456644 settings.normalize();
457645 settings
458646 }
@@ -497,12 +685,14 @@ pub fn update(edit: impl FnOnce(&mut Settings)) {
497685 }
498686 }
499687
500- /// Puts everything but favorites, agents, usage hubs and remote connections
501- /// back to its default.
688+ /// Puts everything but favorites and model preferences, agents, usage hubs
689+ /// and remote connections back to its default.
502690 pub fn restore_defaults() {
503691 update(|settings| {
504692 let kept = Settings {
505693 favorites: std::mem::take(&mut settings.favorites),
694+ hidden_models: std::mem::take(&mut settings.hidden_models),
695+ model_order: std::mem::take(&mut settings.model_order),
506696 providers: std::mem::take(&mut settings.providers),
507697 usage_hubs: std::mem::take(&mut settings.usage_hubs),
508698 remote_server: settings.remote_server,
@@ -515,6 +705,64 @@ pub fn restore_defaults() {
515705 });
516706 }
517707
708+ // ---- secrets ---------------------------------------------------------------
709+
710+ /// Where sensitive environment variables are kept: next to the settings,
711+ /// readable by this user only.
712+ fn secrets_path() -> Option<PathBuf> {
713+ Some(path()?.with_file_name("secrets.json"))
714+ }
715+
716+ fn secret_key(instance: &str, var: &str) -> String {
717+ format!("{instance}/{var}")
718+ }
719+
720+ fn read_secrets() -> std::collections::BTreeMap<String, String> {
721+ secrets_path()
722+ .and_then(|path| std::fs::read_to_string(path).ok())
723+ .and_then(|text| serde_json::from_str(&text).ok())
724+ .unwrap_or_default()
725+ }
726+
727+ /// Whether a sensitive variable has a stored value.
728+ pub fn has_secret(instance: &str, var: &str) -> bool {
729+ read_secrets().contains_key(&secret_key(instance, var.trim()))
730+ }
731+
732+ /// Stores a sensitive variable's value, or forgets it with `None`.
733+ pub fn set_secret(instance: &str, var: &str, value: Option<&str>) {
734+ let mut secrets = read_secrets();
735+ let key = secret_key(instance, var.trim());
736+ match value {
737+ Some(value) => secrets.insert(key, value.to_string()),
738+ None => secrets.remove(&key),
739+ };
740+ write_secrets(&secrets);
741+ }
742+
743+ /// Keeps a sensitive variable's value when the variable is renamed.
744+ pub fn rename_secret(instance: &str, from: &str, to: &str) {
745+ let mut secrets = read_secrets();
746+ if let Some(value) = secrets.remove(&secret_key(instance, from.trim())) {
747+ if !to.trim().is_empty() {
748+ secrets.insert(secret_key(instance, to.trim()), value);
749+ }
750+ write_secrets(&secrets);
751+ }
752+ }
753+
754+ fn write_secrets(secrets: &std::collections::BTreeMap<String, String>) {
755+ let Some(path) = secrets_path() else { return };
756+ let written = path.parent().map(std::fs::create_dir_all).unwrap_or(Ok(())).and_then(|_| {
757+ use std::{io::Write as _, os::unix::fs::OpenOptionsExt as _};
758+ let mut file = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&path)?;
759+ file.write_all(serde_json::to_string_pretty(&secrets).unwrap_or_default().as_bytes())
760+ });
761+ if let Err(err) = written {
762+ eprintln!("vectrum-code: couldn't save {}: {err}", path.display());
763+ }
764+ }
765+
518766 /// `~/bin/claude` → `/home/me/bin/claude`.
519767 pub fn expand_home(path: &str) -> PathBuf {
520768 match (path.strip_prefix("~/"), std::env::var_os("HOME")) {
@@ -534,7 +782,7 @@ pub fn data_dir() -> Option<PathBuf> {
534782
535783 #[cfg(test)]
536784 mod tests {
537- use super::{ThemeMode, parse};
785+ use super::{SendShortcut, ThemeMode, parse};
538786
539787 #[test]
540788 fn old_colorways_become_theme_modes() {
@@ -549,4 +797,26 @@ mod tests {
549797 let saved = parse(r#"{"theme_mode": "dark", "dark_theme": "nord"}"#);
550798 assert_eq!((saved.theme_mode, saved.dark_theme.as_str()), (ThemeMode::Dark, "nord"));
551799 }
800+
801+ #[test]
802+ fn older_fields_carry_over() {
803+ let old = parse(r#"{"send_with_ctrl_enter": true, "group_by_project": true}"#);
804+ assert_eq!(old.send_shortcut, SendShortcut::ModEnter);
805+ assert!(old.legacy_sidebar && old.group_by_project);
806+ // The new fields win once they're saved, and the old ones follow them.
807+ let new = parse(r#"{"send_with_ctrl_enter": true, "send_shortcut": "mod_enter_multiline", "legacy_sidebar": false}"#);
808+ assert_eq!(new.send_shortcut, SendShortcut::ModEnterMultiline);
809+ assert!(!new.send_with_ctrl_enter && !new.group_by_project && !new.legacy_sidebar);
810+ let fresh = parse("{}");
811+ assert_eq!((fresh.send_shortcut, fresh.legacy_sidebar), (SendShortcut::Enter, false));
812+ }
813+
814+ #[test]
815+ fn restore_lists_what_changed() {
816+ let mut settings = parse("{}");
817+ assert!(settings.differing_defaults().is_empty());
818+ settings.chat_width = super::ChatWidth::Wide;
819+ settings.dark_theme = "nord".into();
820+ assert_eq!(settings.differing_defaults(), ["Theme", "Chat width"]);
821+ }
552822 }
+7−5src/ui/changes.rsView File
@@ -102,9 +102,11 @@ fn counts(added: Option<u64>, removed: Option<u64>, p: &Palette) -> Div {
102102 .font_family(metal::MONO)
103103 .text_size(px(11.5))
104104 .map(|this| match (added, removed) {
105- (Some(added), Some(removed)) => this
106- .child(div().text_color(p.green_ink).child(format!("+{}", format_count(added))))
107- .child(div().text_color(p.red).child(format!("−{}", format_count(removed)))),
105+ (Some(added), Some(removed)) => {
106+ let ink = metal::diff_ink(p);
107+ this.child(div().text_color(ink.added).child(format!("+{}", format_count(added))))
108+ .child(div().text_color(ink.removed).child(format!("−{}", format_count(removed))))
109+ }
108110 _ => this.child(div().text_color(p.ink3).child("binary")),
109111 })
110112 }
@@ -344,8 +346,8 @@ fn split_rows(lines: &[DiffLine]) -> Vec<SplitRow<'_>> {
344346 /// A line's sign, background and ink.
345347 fn line_style(kind: LineKind, p: &Palette) -> (&'static str, Option<Hsla>, Hsla) {
346348 match kind {
347- LineKind::Added => ("+", Some(p.green_deep.opacity(0.14)), p.ink),
348- LineKind::Removed => ("−", Some(p.red.opacity(0.12)), p.ink),
349+ LineKind::Added => ("+", Some(metal::diff_ink(p).added_bg), p.ink),
350+ LineKind::Removed => ("−", Some(metal::diff_ink(p).removed_bg), p.ink),
349351 LineKind::Hunk => ("", Some(p.ink.opacity(0.04)), p.ink3),
350352 LineKind::Context => (" ", None, p.ink2),
351353 }
+28−0src/ui/metal.rsView File
@@ -476,6 +476,34 @@ pub fn badge(initials: impl Into<SharedString>, tint: usize, p: &Palette) -> Div
476476 .child(initials.into())
477477 }
478478
479+ /// The colors diffs and change counts use, from the palette, as the Diff
480+ /// colors setting picks them.
481+ pub struct DiffInk {
482+ /// Text for additions: `+12`.
483+ pub added: Hsla,
484+ pub removed: Hsla,
485+ /// The tint behind an added line.
486+ pub added_bg: Hsla,
487+ pub removed_bg: Hsla,
488+ }
489+
490+ pub fn diff_ink(p: &Palette) -> DiffInk {
491+ match crate::settings::get(|settings| settings.diff_colors) {
492+ crate::settings::DiffColors::RedGreen => DiffInk {
493+ added: p.green_ink,
494+ removed: p.red,
495+ added_bg: p.green_deep.opacity(0.14),
496+ removed_bg: p.red.opacity(0.12),
497+ },
498+ crate::settings::DiffColors::BlueOrange => DiffInk {
499+ added: p.blue_ink,
500+ removed: p.tints[0],
501+ added_bg: p.blue_ink.opacity(0.14),
502+ removed_bg: p.tints[0].opacity(0.14),
503+ },
504+ }
505+ }
506+
479507 /// A vertical hairline, for separating toolbar groups.
480508 pub fn vrule(p: &Palette) -> Div {
481509 div().flex_none().w(px(1.)).h(px(16.)).bg(p.rule)
+1−1src/ui/settings.rsView File
@@ -987,7 +987,7 @@ impl SettingsView {
987987 let env: Vec<EnvVar> = form
988988 .env
989989 .iter()
990- .map(|(key, val)| EnvVar { key: value(key), value: val.read(cx).value().to_string() })
990+ .map(|(key, val)| EnvVar { key: value(key), value: val.read(cx).value().to_string(), sensitive: false })
991991 .collect();
992992 let id = form.id.clone();
993993 let unchanged = settings::get(|settings| {