返回 CodeWhale
settings.rs
根目录 / crates / tui / src / settings.rs
1 //! Settings system - Persistent user preferences
2 //!
3 //! Settings are stored at ~/.codewhale/settings.toml, with legacy fallbacks.
4 //!
5 //! There is one persisted settings store. The historical `tui.toml` second
6 //! store is folded into it on load and moved aside with a receipt — see
7 //! [`TuiPrefsMigration`].
8
9 use std::path::{Path, PathBuf};
10
11 use anyhow::{Context, Result};
12 use serde::{Deserialize, Serialize};
13
14 use crate::config::{expand_path, normalize_model_name};
15 use crate::reasoning_preference::ReasoningEffort;
16 use codewhale_config::resolve::Layer;
17 use codewhale_localization::normalize_configured_locale;
18 use codewhale_palette::{normalize_hex_rgb_color, normalize_theme_setting};
19
20 const SETTINGS_FILE_NAME: &str = "settings.toml";
21
22 /// Fresh terminal installs and explicit theme resets share one default.
23 pub(crate) use codewhale_config::settings_schema::DEFAULT_TUI_THEME;
24
25 /// Smallest Top work surface that can show its divider plus the compact
26 /// goal / to-do / Agent projection without turning the rail into invisible
27 /// keyboard state. Older releases accepted two rows, which left only one
28 /// content row and could hide every actionable item behind the goal title.
29 pub(crate) const WORK_SURFACE_TOP_HEIGHT_MIN: u16 = 5;
30 pub(crate) const WORK_SURFACE_TOP_HEIGHT_MAX: u16 = 16;
31 const TUI_PREFS_FILE_NAME: &str = "tui.toml";
32
33 /// How successful structured file mutations are represented in the live
34 /// transcript. Exact evidence is retained for inspection in every mode.
35 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
36 pub enum InlineDiffMode {
37 /// Show a bounded red/green unified diff plus semantic change statistics.
38 #[default]
39 Full,
40 /// Show only bounded semantic change statistics.
41 Summary,
42 /// Keep the calm File outcome row without any inline diff detail.
43 Off,
44 }
45
46 impl InlineDiffMode {
47 #[must_use]
48 pub fn parse(value: &str) -> Self {
49 match value.trim().to_ascii_lowercase().as_str() {
50 "summary" => Self::Summary,
51 "off" => Self::Off,
52 _ => Self::Full,
53 }
54 }
55
56 #[must_use]
57 pub const fn as_setting(self) -> &'static str {
58 match self {
59 Self::Full => "full",
60 Self::Summary => "summary",
61 Self::Off => "off",
62 }
63 }
64 }
65
66 // ============================================================================
67 // tui.toml — folded into settings.toml (0.9.12: one settings store)
68 // ============================================================================
69
70 /// What the one-time `tui.toml` fold did, so a session can say it out loud.
71 ///
72 /// `tui.toml` used to be a second persisted store for `theme`, `font_size`,
73 /// and keybind overrides. Startup never read it, so a theme saved there could
74 /// disagree with `settings.toml` forever and nothing told the user which store
75 /// won. The file is now folded into `settings.toml` at load: a value
76 /// `settings.toml` does not already own explicitly is adopted, a value it does
77 /// own is reported as kept, and a key with no `Settings` field is quarantined
78 /// by name. The original bytes are moved to a dated backup — never deleted,
79 /// never silently dropped.
80 #[derive(Debug, Clone, Default, PartialEq, Eq)]
81 pub struct TuiPrefsMigration {
82 /// The `tui.toml` that was folded.
83 pub source: PathBuf,
84 /// Where its original bytes now live.
85 pub backup: Option<PathBuf>,
86 /// `(settings key, adopted value)` folded into `settings.toml`.
87 pub folded: Vec<(String, String)>,
88 /// `(settings key, tui.toml value, settings.toml value)` — settings.toml
89 /// already owned the key explicitly, so it won.
90 pub kept: Vec<(String, String, String)>,
91 /// `tui.toml` keys with no home in [`Settings`]. Listed, never dropped.
92 pub quarantined: Vec<String>,
93 }
94
95 impl TuiPrefsMigration {
96 /// Whether anything at all is worth telling the user about.
97 #[must_use]
98 pub fn is_empty(&self) -> bool {
99 self.folded.is_empty() && self.kept.is_empty() && self.quarantined.is_empty()
100 }
101
102 /// One localized line per outcome, in the order a reader needs them:
103 /// what moved, what did not, and what was parked.
104 #[must_use]
105 pub fn lines(&self, locale: codewhale_localization::Locale) -> Vec<String> {
106 use codewhale_localization::{MessageId, tr};
107
108 let backup = self
109 .backup
110 .as_ref()
111 .map(|path| path.display().to_string())
112 .unwrap_or_else(|| self.source.display().to_string());
113 let mut lines = Vec::new();
114 for (key, value) in &self.folded {
115 lines.push(
116 tr(locale, MessageId::SettingsTuiPrefsFolded)
117 .replace("{key}", key)
118 .replace("{value}", value),
119 );
120 }
121 for (key, from_prefs, from_settings) in &self.kept {
122 lines.push(
123 tr(locale, MessageId::SettingsTuiPrefsKept)
124 .replace("{key}", key)
125 .replace("{prefs}", from_prefs)
126 .replace("{settings}", from_settings),
127 );
128 }
129 if !self.quarantined.is_empty() {
130 lines.push(
131 tr(locale, MessageId::SettingsTuiPrefsQuarantined)
132 .replace("{keys}", &self.quarantined.join(", "))
133 .replace("{path}", &backup),
134 );
135 }
136 lines
137 }
138 }
139
140 /// The `tui.toml` next to each settings candidate, first existing wins.
141 fn tui_prefs_path_from_settings_candidates(
142 primary: Option<&Path>,
143 legacy_home: Option<&Path>,
144 ) -> Option<PathBuf> {
145 [primary, legacy_home]
146 .into_iter()
147 .flatten()
148 .map(|path| path.with_file_name(TUI_PREFS_FILE_NAME))
149 .find(|path| path.exists())
150 }
151
152 /// Move `path` aside to `tui.toml.migrated-<YYYYMMDD>`, never clobbering an
153 /// existing backup. The bytes are preserved; only the name changes, so the
154 /// dead store cannot reappear as a second source of truth on the next launch.
155 fn back_up_tui_prefs(path: &Path) -> Result<PathBuf> {
156 let stamp = chrono::Local::now().format("%Y%m%d").to_string();
157 let base = format!("{TUI_PREFS_FILE_NAME}.migrated-{stamp}");
158 let mut candidate = path.with_file_name(&base);
159 let mut attempt = 1u32;
160 while candidate.exists() {
161 candidate = path.with_file_name(format!("{base}-{attempt}"));
162 attempt += 1;
163 }
164 std::fs::rename(path, &candidate)
165 .with_context(|| format!("Failed to move {} aside", path.display()))?;
166 Ok(candidate)
167 }
168
169 /// Fold a legacy `tui.toml` into `settings`, returning the receipt.
170 ///
171 /// `explicit` reports whether `settings.toml` named a key itself; an explicit
172 /// value always wins, and the disagreement is recorded rather than resolved
173 /// behind the user's back. When `apply` is false (read-only diagnostics) the
174 /// values are still folded in memory but no file is moved or written.
175 fn fold_tui_prefs(
176 settings: &mut Settings,
177 explicit: &std::collections::BTreeSet<String>,
178 prefs_path: &Path,
179 apply: bool,
180 ) -> Option<TuiPrefsMigration> {
181 let raw = std::fs::read_to_string(prefs_path).ok()?;
182 let mut receipt = TuiPrefsMigration {
183 source: prefs_path.to_path_buf(),
184 ..TuiPrefsMigration::default()
185 };
186 match toml::from_str::<toml::Value>(&raw) {
187 Ok(toml::Value::Table(table)) => {
188 for (key, value) in table {
189 // `theme` is the only tui.toml key with a `Settings` field.
190 // `font_size` and `[keybinds]` never had one, so they are
191 // quarantined by name instead of being thrown away.
192 if key != "theme" {
193 receipt.quarantined.push(key);
194 continue;
195 }
196 let Some(theme) = value.as_str().map(str::to_string) else {
197 receipt.quarantined.push(key);
198 continue;
199 };
200 let normalized = normalize_settings_theme(&theme);
201 if explicit.contains("theme") {
202 if normalized != settings.theme {
203 receipt.kept.push((key, normalized, settings.theme.clone()));
204 }
205 } else {
206 settings.theme = normalized.clone();
207 receipt.folded.push((key, normalized));
208 }
209 }
210 }
211 _ => {
212 // Unreadable bytes are still the user's: park the whole file
213 // under its own name rather than guessing at its contents.
214 receipt.quarantined.push(TUI_PREFS_FILE_NAME.to_string());
215 }
216 }
217 receipt.quarantined.sort();
218
219 if apply {
220 match back_up_tui_prefs(prefs_path) {
221 Ok(backup) => receipt.backup = Some(backup),
222 Err(error) => {
223 tracing::warn!("failed to move {} aside: {error:#}", prefs_path.display());
224 }
225 }
226 }
227 Some(receipt)
228 }
229
230 /// User settings with defaults
231 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
232 pub struct PinnedModel {
233 /// Exact configured provider identity; labels never replace this value.
234 pub provider: String,
235 /// Exact provider-owned model id.
236 pub model: String,
237 /// Optional presentation-only label.
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub label: Option<String>,
240 }
241
242 #[derive(Debug, Clone, Serialize, Deserialize)]
243 #[serde(default)]
244 pub struct Settings {
245 /// Auto-compact conversations when they approach the model limit.
246 pub auto_compact: bool,
247 /// Context-window percentage that triggers pre-send auto-compaction when
248 /// `auto_compact` is enabled. The hard token floor still applies.
249 pub auto_compact_threshold_percent: f64,
250 /// Whether the persisted settings file expressed an auto-compaction
251 /// preference. Runtime defaults must not be written back as user intent
252 /// when an unrelated setting is saved.
253 #[serde(skip)]
254 pub(crate) auto_compact_explicit: bool,
255 /// Reduce status noise and collapse details more aggressively
256 pub calm_mode: bool,
257 /// Dense tool-run collapse mode: compact, expanded, or calm.
258 pub tool_collapse_mode: String,
259 /// Reduce decorative motion. This must never synthesize model text speed;
260 /// streaming follows upstream deltas in both modes.
261 pub low_motion: bool,
262 /// Set when the persisted file existed but could not be parsed and the
263 /// values above are defaults. Never serialized; surfaces must not present
264 /// these defaults as saved.
265 #[serde(skip)]
266 pub load_error: Option<String>,
267 /// Enable expressive live-state motion. This affects chrome and state
268 /// affordances only; model text always follows upstream stream deltas.
269 pub fancy_animations: bool,
270 /// Focus-context texture prototype for modal views (#4823): `off`
271 /// (default), `scrim` dims the area outside the focused modal, `grain`
272 /// sprinkles deterministic dots over blank cells there. Static texture,
273 /// never obscures text; unknown values fall back to `off` at render time.
274 pub focus_texture: String,
275 /// Ocean Tasks / To-do / Workers rail placement: top, left, or right.
276 /// The lower edge remains owned by the composer and phase footer.
277 pub work_surface_placement: String,
278 /// Remembered total height (content plus divider) for top Work placement.
279 pub work_surface_top_height: u16,
280 /// Remembered total width (content plus divider) for side Work placement.
281 pub work_surface_side_width: u16,
282 /// Which panel the rail shows: tasks, agents, context, or pinned.
283 /// Orthogonal to `work_surface_placement` (rail unification, 0.9.4).
284 pub rail_panel: String,
285 /// Runtime-only: whether the loaded settings document explicitly named
286 /// `rail_panel`. The sidebar→rail migration must not override an
287 /// explicit choice that happens to equal the default ("tasks").
288 #[serde(skip)]
289 pub(crate) rail_panel_explicit: bool,
290 /// Runtime-only: whether the loaded settings document explicitly named
291 /// `work_surface_placement`. A legacy hidden sidebar must become `off`,
292 /// unless the user had already chosen a first-class rail placement.
293 #[serde(skip)]
294 pub(crate) work_surface_placement_explicit: bool,
295 /// Runtime-only 30 FPS cap for terminals that flicker at high redraw
296 /// rates. Separate from accessibility motion and text delivery.
297 #[serde(skip)]
298 pub constrained_frame_rate: bool,
299 /// Enable terminal bracketed-paste mode. Default true. Disable if your
300 /// terminal mishandles the `\e[?2004h` escape (rare; some legacy
301 /// terminals over SSH+screen multiplex without the cap).
302 pub bracketed_paste: bool,
303 /// Enable rapid-key paste-burst detection for terminals that do not emit
304 /// bracketed-paste events. Independent from `bracketed_paste`.
305 pub paste_burst_detection: bool,
306 /// Maximum number of file-mention popup candidates retained before the
307 /// composer renders its visible window. The widget paginates by terminal
308 /// height, so this is a data-side cap rather than a visible-row budget.
309 pub mention_menu_limit: usize,
310 /// Maximum workspace depth for `@`-mention completion walks. `0` means
311 /// unlimited depth; use with care in very large repositories.
312 pub mention_walk_depth: usize,
313 /// `@`-mention completion behavior: fuzzy workspace search or deterministic
314 /// directory browser.
315 pub mention_menu_behavior: String,
316 /// Show thinking blocks from the model
317 pub show_thinking: bool,
318 /// When true, thinking blocks render expanded by default instead of
319 /// collapsed. Space still toggles collapse/expand. Useful for SSH/tmux
320 /// users where the Space key may be captured by the terminal layer.
321 #[serde(default)]
322 pub thinking_default_expanded: bool,
323 /// Collapsed completed-thought preview rows. Default 2 (compact).
324 /// Set `10` for the older dump, or `0` for header-only. Full expand is
325 /// still `thinking_default_expanded` / Space.
326 #[serde(default = "default_thinking_preview_lines")]
327 pub thinking_preview_lines: usize,
328 /// Keep thinking visible while disabling its filled background treatment.
329 pub thinking_highlight: bool,
330 /// When true, Help/shortcuts groups start expanded. Default false folds
331 /// the long tail. Type-to-filter still unfolds matches.
332 #[serde(default)]
333 pub help_expand_groups: bool,
334 /// Show quiet, action-triggered command discovery tips.
335 #[serde(default = "default_true")]
336 pub contextual_tips: bool,
337 /// Pin the last user prompt at the top of the transcript when it has
338 /// scrolled off. Default on.
339 #[serde(default = "default_true")]
340 pub pin_last_prompt: bool,
341 /// Show detailed tool output
342 pub show_tool_details: bool,
343 /// Successful structured File mutation evidence: full, summary, or off.
344 /// This affects inline presentation only; exact evidence remains available
345 /// through the tool-details route in every mode.
346 pub inline_diffs: String,
347 /// UI locale: auto, en, ja, zh-Hans, zh-Hant, pt-BR, es-419, vi, ko,
348 /// ca, de, fr, id, hi, ru, uk.
349 /// Every shipped pack holds full `en.json` parity; nothing falls back.
350 pub locale: String,
351 /// Named UI theme. `"underwater"` is the fresh-install default: a dark
352 /// navy water column. `"shoreline"` is the warm charcoal alternative.
353 /// `"terminal"` fully inherits the
354 /// host terminal's foreground/background. `"system"`, `"dark"`,
355 /// `"light"`, `"grayscale"`, and the community presets:
356 /// `"catppuccin-mocha"`, `"tokyo-night"`, `"dracula"`,
357 /// `"gruvbox-dark"`. The `background_color` setting still overrides the
358 /// surface color on top of the resolved theme.
359 pub theme: String,
360 /// Optional main TUI background color as a 6-digit hex RGB value.
361 pub background_color: Option<String>,
362 /// Composer layout density: compact, comfortable, spacious
363 pub composer_density: String,
364 /// Show a border around the composer input area
365 pub composer_border: bool,
366 /// Keep bare Enter available for multiline drafting. When enabled,
367 /// Shift+Enter submits; Ctrl+J and Alt+Enter remain newline shortcuts.
368 #[serde(default)]
369 pub composer_multiline_mode: bool,
370 /// Composer editing mode: "normal" (default) or "vim" for modal editing.
371 /// When set to "vim" the composer starts in Normal mode; press i/a/o to
372 /// enter Insert mode and Esc to return to Normal.
373 pub composer_vim_mode: String,
374 /// Transcript spacing rhythm: compact, comfortable, spacious
375 pub transcript_spacing: String,
376 /// Default mode: "agent" (Act), "plan", or "operate". Legacy permission
377 /// shorthands are accepted for migration but never advertised as modes.
378 pub default_mode: String,
379 /// Legacy sidebar width as percentage of terminal width. Load-only
380 /// migration shim (0.9.4 rail unification): read by
381 /// `migrate_sidebar_settings_to_rail`, never written back.
382 #[serde(skip_serializing)]
383 pub sidebar_width_percent: u16,
384 /// Legacy sidebar focus mode: pinned, auto, tasks, agents, context,
385 /// hidden. Load-only migration shim, never written back.
386 #[serde(skip_serializing)]
387 pub sidebar_focus: String,
388 /// Enable the session-context panel (#504). Shows working set, tokens,
389 /// cost, MCP/LSP status, cycle count, and memory info.
390 pub context_panel: bool,
391 /// Show the persistent Sessions rail in the sidebar (#2934).
392 ///
393 /// Off by default: the rail spends sidebar rows that Work, Activity, and
394 /// Agents already compete for, so it is opt-in rather than something a
395 /// user discovers by having their layout change under them.
396 #[serde(default, skip_serializing_if = "is_false")]
397 pub sessions_rail: bool,
398 /// Reattach to this workspace's most recent session on startup (#2934).
399 ///
400 /// Off by default. `--resume`/`--continue` remain the explicit paths and
401 /// always take precedence; when this is on, startup still refuses to
402 /// resume an archived, unreadable, or foreign-workspace session and falls
403 /// back to a fresh transcript with a receipt. See
404 /// [`crate::session_resume`] for the decision table.
405 #[serde(default, skip_serializing_if = "is_false")]
406 pub session_auto_resume: bool,
407 /// Cost display currency: usd or cny.
408 pub cost_currency: String,
409 /// Maximum number of input history entries to save
410 pub max_input_history: usize,
411 /// Archived startup provider used only to migrate older settings into config.
412 pub default_provider: Option<String>,
413 /// Archived DeepSeek fallback used only by the config selection migration.
414 pub default_model: Option<String>,
415 /// Default reasoning effort selected from the TUI model picker.
416 /// `None` falls back to `config.toml` and then the runtime default.
417 pub reasoning_effort: Option<String>,
418 /// TUI-only Shift+Tab posture: ask, auto-review, or full-access.
419 /// An explicit/managed `config.toml` approval policy always takes
420 /// precedence, so this preference cannot loosen project requirements.
421 /// This is **tool-approval posture**, not filesystem scope — see
422 /// [`Self::sandbox_mode`].
423 #[serde(default, skip_serializing_if = "Option::is_none")]
424 pub permission_posture: Option<String>,
425 /// Filesystem sandbox scope, independent of approval posture:
426 /// `read-only | workspace-write | danger-full-access | external-sandbox`.
427 /// Surfaced in Settings and the shell so "Full Access" (approval) is
428 /// never confused with unrestricted filesystem writes.
429 #[serde(default, skip_serializing_if = "Option::is_none")]
430 pub sandbox_mode: Option<String>,
431 /// Archived provider model choices used only by the config selection
432 /// migration. Preserve them on unrelated settings saves until migrated.
433 pub provider_models: Option<std::collections::HashMap<String, String>>,
434 /// Legacy additive picker list written by older builds on every model
435 /// switch. Nothing reads it any more (#6533): the picker ranks by recent
436 /// use instead. Parsed and preserved only so old files load and survive
437 /// unrelated saves until a cleanup removes the table.
438 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub enabled_models: Option<std::collections::HashMap<String, Vec<String>>>,
440 /// Exact provider/model tuples pinned to the top of model choosers, in
441 /// user-defined order. Stale entries remain persisted and visible.
442 #[serde(default, skip_serializing_if = "Vec::is_empty")]
443 pub pinned_models: Vec<PinnedModel>,
444 /// Header status indicator next to the effort chip. Cycles through a
445 /// per-turn animation keyed off `App::turn_started_at`:
446 /// - `"cw"` (default): static typographic Codewhale mark.
447 /// - `"whale"`: historical `🐳 → 🐋` 12-frame sequence
448 /// originally shipped in v0.3.5, removed in v0.8.x's "smoother TUI
449 /// streaming" pass, restored in v0.8.30. Idle frame is a steady `🐳`.
450 /// - `"dots"`: the 6-frame geometric sequence (`◍ ◉ ◌ ◌ ◉ ◍`) that
451 /// replaced the whale during the dots era.
452 /// - `"off"`: hide the indicator entirely.
453 pub status_indicator: String,
454 /// Whether to wrap each draw in DEC mode 2026 synchronized output
455 /// (`\x1b[?2026h` … `\x1b[?2026l`). Synchronized output asks the
456 /// terminal to defer rendering until the whole frame is staged so
457 /// GPU-accelerated terminals (Ghostty, VS Code, Kitty, WezTerm)
458 /// don't flash a blank intermediate frame.
459 ///
460 /// - `"auto"` (default): emit DEC 2026 unless an environment signal
461 /// says the active terminal mishandles it (currently Ptyxis 50.x
462 /// on VTE 0.84.x — see [`Settings::apply_env_overrides`]).
463 /// - `"on"`: always emit DEC 2026 (override the auto opt-out).
464 /// - `"off"`: never emit DEC 2026. Use this if your terminal flashes
465 /// the whole screen on every redraw — most often Ptyxis on
466 /// Ubuntu 26.04 today; historically also some legacy ssh+screen
467 /// stacks. The cost of `off` is brief tearing on terminals that
468 /// *do* support DEC 2026; it is purely a rendering-quality knob,
469 /// not a correctness one.
470 pub synchronized_output: String,
471 /// Follow symbolic links during workspace file discovery walks (`@`-mention
472 /// completion, fuzzy resolve, and the file-index builder). When `false`
473 /// (default) symlinked directories are skipped, which keeps walks fast and
474 /// avoids accidentally traversing into system paths. Set to `true` to
475 /// support symlink-based multi-project workspaces where several project
476 /// directories are symlinked into a single hub directory.
477 ///
478 /// **Note**: The walker has built-in cycle detection that skips already-
479 /// visited real paths, so symlink loops (A→B→A) will not cause infinite
480 /// recursion. However, enabling this on workspaces with symlinks that
481 /// point to large directory trees (e.g. `/usr`, home directories) can
482 /// significantly increase first-turn latency and memory usage.
483 pub workspace_follow_symlinks: bool,
484 /// One-time Fleet + Hotbar introduction has been shown. Drives a single
485 /// launch nudge (see `App::maybe_show_feature_intro`) so returning users
486 /// see it exactly once and never on subsequent launches.
487 pub feature_intro_shown: bool,
488 /// One-time YOLO deprecation toast has been shown. Suppresses the repeat
489 /// toast after the first sighting per install (persisted across sessions).
490 pub yolo_deprecation_shown: bool,
491 /// Round 3 (2026-09-01) moved the work bar under the composer. Every
492 /// settings.toml saved before that carries `work_surface_placement =
493 /// "top"` — the old default, persisted verbatim by ordinary saves, not a
494 /// choice anyone made. This flag records that the one-time `top` →
495 /// `bottom` migration ran, so a user who picks `top` afterwards keeps it.
496 #[serde(default)]
497 pub work_surface_bottom_migrated: bool,
498 /// Persisted impression counts for action-triggered, ephemeral product
499 /// guidance. Keys are stable tip identifiers; values are bounded by the
500 /// behavioral-tip engine and omitted entirely before the first sighting.
501 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
502 pub behavioral_tip_impressions: std::collections::BTreeMap<String, u8>,
503 /// Plugin names explicitly dismissed from proactive suggestions. Manual
504 /// plugin commands remain available. Names are stored in lowercase.
505 #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
506 pub dismissed_plugin_suggestions: std::collections::BTreeSet<String>,
507 /// Persisted use counts for the Tideline footer key hints. Keys are the
508 /// stable hint identifiers in `crate::tui::footer_hints`; a hint retires
509 /// to its bare state once its binding has been used enough times.
510 /// Omitted entirely before the first recorded use.
511 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
512 pub footer_hint_uses: std::collections::BTreeMap<String, u8>,
513 /// True only for the current load when `default_mode = "yolo"` was read
514 /// from an older settings file. App startup uses this provenance to migrate
515 /// the old bundled Full Access choice without weakening project or managed
516 /// approval policy. It is never written back to disk.
517 #[serde(skip)]
518 pub(crate) legacy_yolo_default: bool,
519 /// Receipt for the one-time `tui.toml` fold performed by this load.
520 /// Never serialized: it describes what happened to a file, not a setting.
521 #[serde(skip)]
522 pub(crate) tui_prefs_migration: Option<TuiPrefsMigration>,
523 /// Which layer supplied the in-force value of each schema key: user
524 /// config for keys `settings.toml` named at load, the default for the
525 /// rest, session for keys `set()` touched since. Runtime only — the
526 /// resolver reads it, disk never sees it. CLI flags (2D) and managed
527 /// policy / project producers mark their own layers when they land.
528 #[serde(skip)]
529 pub(crate) provenance: std::collections::BTreeMap<String, Layer>,
530 }
531
532 impl Default for Settings {
533 fn default() -> Self {
534 Self {
535 // Keep the persisted fallback `false`; startup code enables
536 // auto-compaction by known model window when the user has not saved
537 // an explicit preference. This preserves an explicit opt-out while
538 // making long-session continuity the default runtime behavior.
539 auto_compact: false,
540 auto_compact_threshold_percent: 80.0,
541 auto_compact_explicit: false,
542 // #4095: default presentation is compact/calm; verbose detail is opt-in.
543 calm_mode: true,
544 tool_collapse_mode: "compact".to_string(),
545 low_motion: false,
546 load_error: None,
547 fancy_animations: true,
548 focus_texture: "off".to_string(),
549
550 // Round 3 (2026-09-01): the bar's information lives under the
551 // composer. Side rails are opt-in and fall back to the top strip
552 // on narrow terminals.
553 work_surface_placement: "bottom".to_string(),
554 // Cap, not fixed height: the top strip auto-fits its rows and
555 // only grows to this many lines (user request, 2026-07-23).
556 work_surface_top_height: 8,
557 work_surface_side_width: 30,
558 rail_panel: "tasks".to_string(),
559 rail_panel_explicit: false,
560 work_surface_placement_explicit: false,
561 constrained_frame_rate: false,
562 bracketed_paste: true,
563 paste_burst_detection: true,
564 mention_menu_limit: 128,
565 mention_walk_depth: 10,
566 mention_menu_behavior: "fuzzy".to_string(),
567 // Reasoning is useful when explicitly requested, but it should
568 // never displace the actual conversation in the default TUI.
569 show_thinking: false,
570 thinking_default_expanded: false,
571 thinking_preview_lines: default_thinking_preview_lines(),
572 thinking_highlight: true,
573 help_expand_groups: false,
574 contextual_tips: true,
575 pin_last_prompt: true,
576 show_tool_details: false,
577 inline_diffs: "full".to_string(),
578 locale: "auto".to_string(),
579 theme: DEFAULT_TUI_THEME.to_string(),
580 background_color: None,
581 composer_density: "comfortable".to_string(),
582 composer_border: true,
583 composer_multiline_mode: false,
584 composer_vim_mode: "normal".to_string(),
585 transcript_spacing: "comfortable".to_string(),
586 default_mode: "agent".to_string(),
587 sidebar_width_percent: 28,
588 sidebar_focus: "auto".to_string(),
589 context_panel: false,
590 sessions_rail: false,
591 session_auto_resume: false,
592 cost_currency: "usd".to_string(),
593 max_input_history: 100,
594 default_provider: None,
595 default_model: None,
596 reasoning_effort: None,
597 permission_posture: None,
598 sandbox_mode: None,
599 provider_models: None,
600 enabled_models: None,
601 pinned_models: Vec::new(),
602 // The whale lives in the terminal window title (OSC 0). The in-app
603 // header defaults to the static typographic `cw` mark so the two
604 // surfaces do not compete with a second spinner.
605 status_indicator: "cw".to_string(),
606 synchronized_output: "auto".to_string(),
607 workspace_follow_symlinks: false,
608 feature_intro_shown: false,
609 yolo_deprecation_shown: false,
610 work_surface_bottom_migrated: false,
611 behavioral_tip_impressions: std::collections::BTreeMap::new(),
612 dismissed_plugin_suggestions: std::collections::BTreeSet::new(),
613 footer_hint_uses: std::collections::BTreeMap::new(),
614 legacy_yolo_default: false,
615 tui_prefs_migration: None,
616 provenance: std::collections::BTreeMap::new(),
617 }
618 }
619 }
620
621 /// The `calm` transcript preset (#3478): a coherent "beautiful/calm" bundle that
622 /// favors a quiet, readable transcript over debug-dense output. Presentation
623 /// only, and evidence-preserving — `show_thinking` is deliberately left untouched
624 /// (thinking stays visible) and tool runs only have their inline detail
625 /// collapsed, never hidden. Keyed by [`Settings::set`] names so the preset and a
626 /// single-key `/config` set share one validation path.
627 pub const CALM_PRESET_FIELDS: &[(&str, &str)] = &[
628 ("calm_mode", "true"),
629 ("tool_collapse", "calm"),
630 ("transcript_spacing", "compact"),
631 ("low_motion", "true"),
632 ("fancy_animations", "false"),
633 ("show_tool_details", "false"),
634 ];
635
636 fn normalize_work_surface_placement(value: &str) -> &'static str {
637 match value.trim().to_ascii_lowercase().as_str() {
638 "top" => "top",
639 "bottom" => "bottom",
640 "left" => "left",
641 "right" => "right",
642 "off" => "off",
643 // Round 3 (2026-09-01): unknown values fall back to the product
644 // default — the bar lives under the composer.
645 _ => "bottom",
646 }
647 }
648
649 fn normalize_rail_panel(value: &str) -> &'static str {
650 match value.trim().to_ascii_lowercase().as_str() {
651 "agents" => "agents",
652 "background" => "background",
653 "files" => "files",
654 "notepad" => "notepad",
655 "context" => "context",
656 "git" => "git",
657 "price" => "price",
658 "watch" => "watch",
659 // `pinned` folded into the tasks view (2026-09-02 dock views).
660 _ => "tasks",
661 }
662 }
663
664 /// Rail unification (0.9.4): carry the classic sidebar's settings forward
665 /// instead of stranding them. `sidebar_focus` picks the rail panel —
666 /// pinned/tasks/agents/context map onto the same-named panels, auto folds
667 /// into the auto-fitting Tasks panel (it is the shipped default for
668 /// `sidebar_focus`, and "show work when there is work" is what Tasks does;
669 /// folding it into the always-on Pinned strip inverted that intent for every
670 /// upgrading user), and hidden turns the rail off.
671 /// `sidebar_width_percent` maps onto the absolute side width at a
672 /// 120-column reference. Auto-collapse itself is deliberately dropped: the
673 /// rail hides via placement off. Explicit new keys win over migrated ones.
674 fn migrate_sidebar_settings_to_rail(s: &mut Settings) {
675 match s.sidebar_focus.trim().to_ascii_lowercase().as_str() {
676 "hidden" | "hide" | "closed" | "off" | "none" => {
677 // A legacy hidden sidebar is an explicit intent. Preserve it even
678 // now that fresh sessions prefer the responsive left rail, but do
679 // not override a newer placement the user explicitly saved.
680 if !s.work_surface_placement_explicit {
681 s.work_surface_placement = "off".to_string();
682 }
683 }
684 // #5141 let users pin a dedicated sessions panel in the classic
685 // sidebar; on the unified rail the equivalent surface is the
686 // first-class sessions rail, so carry the intent forward by
687 // enabling it.
688 "sessions" | "sessions_rail" | "session_history" => {
689 s.sessions_rail = true;
690 }
691 panel @ ("pinned" | "work" | "plan" | "todos" | "tasks" | "activity" | "live"
692 | "running" | "agents" | "subagents" | "sub-agents" | "context" | "session"
693 // `rail_panel == "tasks"` is the default, so only treat it as unset
694 // when the document did not name the key explicitly. Failing the
695 // guard falls through to the no-op arm below, which is exactly what
696 // the old nested `if` did.
697 | "auto")
698 if s.rail_panel == "tasks" && !s.rail_panel_explicit =>
699 {
700 s.rail_panel = match panel {
701 // `auto` is the shipped *default* for `sidebar_focus`, so
702 // this arm runs for anyone who has a settings.toml at all
703 // — even one that only sets `theme`. Auto-collapse meant
704 // "show work when there is work", which is exactly the
705 // Tasks panel (it auto-fits, and an empty projection
706 // reserves no rows). Folding it into the always-on Pinned
707 // strip inverted the intent and made a 4-row band the
708 // effective default for every upgrading user.
709 "tasks" | "activity" | "live" | "running" | "auto" => "tasks",
710 "agents" | "subagents" | "sub-agents" => "agents",
711 "context" | "session" => "context",
712 _ => "pinned",
713 }
714 .to_string();
715 }
716 _ => {}
717 }
718 if s.sidebar_width_percent != 28 {
719 let cols = (u32::from(s.sidebar_width_percent) * 120 / 100) as u16;
720 s.work_surface_side_width = cols.clamp(26, 80);
721 }
722 }
723
724 fn normalize_inline_diffs(value: &str) -> &'static str {
725 InlineDiffMode::parse(value).as_setting()
726 }
727
728 /// The `(key, value)` fields a named preset applies, or `None` for an unknown
729 /// name. Single source of truth shared by [`Settings::apply_preset`] and the
730 /// `/config preset` command so the bundle is never defined twice.
731 #[must_use]
732 pub fn preset_fields(name: &str) -> Option<&'static [(&'static str, &'static str)]> {
733 match name.trim().to_ascii_lowercase().as_str() {
734 "calm" => Some(CALM_PRESET_FIELDS),
735 _ => None,
736 }
737 }
738
739 impl Settings {
740 /// Get the canonical settings file path.
741 ///
742 /// New writes should target `~/.codewhale/settings.toml`. Legacy
743 /// DeepSeek-branded paths remain readable as fallbacks during load, but we
744 /// no longer surface them as the primary path in `/config`.
745 pub fn path() -> Result<PathBuf> {
746 let (primary, _legacy_home, legacy_config_dir) = settings_path_candidates();
747 primary.or(legacy_config_dir).ok_or_else(|| {
748 anyhow::anyhow!("Failed to resolve settings path: no config directory found.")
749 })
750 }
751
752 /// Load settings from disk, or return defaults if not found
753 pub fn load() -> Result<Self> {
754 let mut settings = Self::load_persisted()?;
755 settings.apply_env_overrides();
756 Ok(settings)
757 }
758
759 /// Load settings for a diagnostic without migrating a legacy file.
760 ///
761 /// This preserves the same candidate precedence, parser normalization, and
762 /// environment overlays as [`Settings::load`]. Unlike an interactive
763 /// startup, diagnostics must not create `~/.codewhale/settings.toml` just
764 /// because they inspected a legacy `~/.deepseek/settings.toml` file.
765 pub(crate) fn load_read_only() -> Result<Self> {
766 let mut settings = Self::load_persisted_read_only()?;
767 settings.apply_env_overrides();
768 Ok(settings)
769 }
770
771 /// Read archived route preferences from the user-global settings store.
772 ///
773 /// Canonical config migration must not inherit a project config's sibling
774 /// settings or runtime environment overlays, and must not migrate files.
775 pub(crate) fn load_legacy_route_preferences_read_only() -> Result<Self> {
776 let (primary, legacy_home, legacy_config_dir) = settings_path_candidates_for_scope(false);
777 let settings = Self::load_persisted_from_candidates_with_migration(
778 primary,
779 legacy_home,
780 legacy_config_dir,
781 false,
782 )?;
783 // Interactive readers may recover with defaults, but migration must
784 // not commit those defaults as if the archived selection were read.
785 anyhow::ensure!(settings.load_error.is_none(), "settings.toml: invalid TOML");
786 Ok(settings)
787 }
788
789 /// Load the normalized values stored on disk without terminal/runtime
790 /// overlays. Configuration editors use this path so a value labelled
791 /// "saved" never silently reports a tmux, SSH, or accessibility override.
792 pub(crate) fn load_persisted() -> Result<Self> {
793 with_settings_transaction(SettingsTransaction::load)
794 }
795
796 /// Load persisted values while the caller already holds the settings
797 /// process mutex and adjacent file lock.
798 fn load_persisted_locked() -> Result<Self> {
799 let (primary, legacy_home, legacy_config_dir) = settings_path_candidates();
800 Self::load_persisted_from_candidates(primary, legacy_home, legacy_config_dir)
801 }
802
803 /// Load normalized disk values for a diagnostic without creating a
804 /// primary settings file from a legacy fallback.
805 fn load_persisted_read_only() -> Result<Self> {
806 let (primary, legacy_home, legacy_config_dir) = settings_path_candidates();
807 Self::load_persisted_from_candidates_with_migration(
808 primary,
809 legacy_home,
810 legacy_config_dir,
811 false,
812 )
813 }
814
815 fn load_persisted_from_candidates(
816 primary: Option<PathBuf>,
817 legacy_home: Option<PathBuf>,
818 legacy_config_dir: Option<PathBuf>,
819 ) -> Result<Self> {
820 Self::load_persisted_from_candidates_with_migration(
821 primary,
822 legacy_home,
823 legacy_config_dir,
824 true,
825 )
826 }
827
828 fn load_persisted_from_candidates_with_migration(
829 primary: Option<PathBuf>,
830 legacy_home: Option<PathBuf>,
831 legacy_config_dir: Option<PathBuf>,
832 migrate_legacy_file: bool,
833 ) -> Result<Self> {
834 #[cfg(test)]
835 {
836 crate::test_support::with_test_state_io_lock(|| {
837 Self::load_persisted_from_candidates_with_migration_unlocked(
838 primary,
839 legacy_home,
840 legacy_config_dir,
841 migrate_legacy_file,
842 )
843 })
844 }
845 #[cfg(not(test))]
846 Self::load_persisted_from_candidates_with_migration_unlocked(
847 primary,
848 legacy_home,
849 legacy_config_dir,
850 migrate_legacy_file,
851 )
852 }
853
854 fn load_persisted_from_candidates_with_migration_unlocked(
855 primary: Option<PathBuf>,
856 legacy_home: Option<PathBuf>,
857 legacy_config_dir: Option<PathBuf>,
858 migrate_legacy_file: bool,
859 ) -> Result<Self> {
860 let write_path = primary
861 .as_ref()
862 .cloned()
863 .or_else(|| legacy_config_dir.clone())
864 .ok_or_else(|| {
865 anyhow::anyhow!("Failed to resolve settings path: no config directory found.")
866 })?;
867 let tui_prefs_path =
868 tui_prefs_path_from_settings_candidates(primary.as_deref(), legacy_home.as_deref());
869 let read_path =
870 resolve_settings_path_from_candidates(primary, legacy_home, legacy_config_dir)
871 .unwrap_or_else(|_| write_path.clone());
872
873 let mut explicit_keys = std::collections::BTreeSet::new();
874 let mut settings = if !read_path.exists() {
875 Self::default()
876 } else {
877 let content = std::fs::read_to_string(&read_path)
878 .with_context(|| format!("Failed to read settings from {}", read_path.display()))?;
879 let parsed_document = toml::from_str::<toml::Value>(&content).ok();
880 let mut s: Settings = match toml::from_str(&content) {
881 Ok(s) => s,
882 Err(e) => {
883 tracing::warn!(
884 "Failed to parse {} (using defaults): {e:#}",
885 read_path.display()
886 );
887 // Keep the app running on defaults, but carry the failure
888 // so a settings surface never labels them as saved.
889 Self {
890 load_error: Some(format!("{}: {e}", read_path.display())),
891 ..Self::default()
892 }
893 }
894 };
895 // Which keys the document named itself. An explicit value is user
896 // intent and always wins over a default or a migrated one.
897 explicit_keys.extend(
898 parsed_document
899 .as_ref()
900 .and_then(toml::Value::as_table)
901 .into_iter()
902 .flat_map(|table| table.keys().cloned()),
903 );
904 // A persisted threshold is itself an explicit request for
905 // auto-compaction. Older versions accepted this setting while
906 // leaving the default `auto_compact = false`, silently turning the
907 // requested trigger into a no-op. Preserve an explicit boolean
908 // opt-out, but make threshold-only files effective on load.
909 s.auto_compact_explicit = parsed_document
910 .as_ref()
911 .is_some_and(auto_compact_explicitly_configured_in_document);
912 s.rail_panel_explicit = explicit_keys.contains("rail_panel");
913 s.work_surface_placement_explicit = explicit_keys.contains("work_surface_placement");
914 if parsed_document.as_ref().is_some_and(|document| {
915 document.as_table().is_some_and(|table| {
916 !table.contains_key("auto_compact")
917 && (table.contains_key("auto_compact_threshold")
918 || table.contains_key("auto_compact_threshold_percent"))
919 })
920 }) {
921 s.auto_compact = true;
922 }
923
924 // Compat boundary (2026-09-02): `ocean_treatment` was a modifier
925 // on `theme`; the painted field is now the `underwater` theme
926 // itself. Fold any persisted deepsea treatment into
927 // `theme = "underwater"`, then drop the retired key on the next
928 // ordinary save (the struct simply has no such field).
929 if let Some(_treatment) = parsed_document
930 .as_ref()
931 .and_then(toml::Value::as_table)
932 .and_then(|table| table.get("ocean_treatment"))
933 .and_then(toml::Value::as_str)
934 .filter(|treatment| {
935 matches!(
936 treatment.trim().to_ascii_lowercase().as_str(),
937 "deepsea" | "underwater" | "ombre" | "gradient" | "classic"
938 )
939 })
940 {
941 s.theme = "underwater".to_string();
942 }
943
944 // "yolo" used to bundle two independent choices: Agent mode and
945 // unrestricted approvals. Keep that behavior on upgrade, but
946 // store/show the two choices explicitly so Settings does not claim
947 // the app starts in a fictional mode.
948 let legacy_yolo_default = s.default_mode.trim().eq_ignore_ascii_case("yolo");
949 s.legacy_yolo_default = legacy_yolo_default;
950 s.default_mode = if legacy_yolo_default {
951 "agent".to_string()
952 } else {
953 normalize_mode(&s.default_mode).to_string()
954 };
955 s.composer_density = normalize_composer_density(&s.composer_density).to_string();
956 s.transcript_spacing = normalize_transcript_spacing(&s.transcript_spacing).to_string();
957 s.tool_collapse_mode = normalize_tool_collapse_mode(&s.tool_collapse_mode).to_string();
958 s.sidebar_focus = normalize_sidebar_focus(&s.sidebar_focus).to_string();
959 // Rail unification (0.9.4) migration: the classic sidebar is
960 // gone, so its settings carry forward instead of stranding.
961 migrate_sidebar_settings_to_rail(&mut s);
962 s.status_indicator = normalize_status_indicator(&s.status_indicator).to_string();
963 s.work_surface_placement =
964 normalize_work_surface_placement(&s.work_surface_placement).to_string();
965 // Round 3 placement migration: a persisted `top` from before the
966 // default moved is the old default, not a preference. Move it
967 // once and remember; the next ordinary save persists both.
968 if !s.work_surface_bottom_migrated {
969 if s.work_surface_placement == "top" {
970 s.work_surface_placement = "bottom".to_string();
971 }
972 s.work_surface_bottom_migrated = true;
973 }
974 s.rail_panel = normalize_rail_panel(&s.rail_panel).to_string();
975 // Migrate the unreadable 2..=4 legacy range in memory. The next
976 // ordinary settings transaction persists the normalized value;
977 // loading settings remains a read-only operation.
978 s.work_surface_top_height = s
979 .work_surface_top_height
980 .clamp(WORK_SURFACE_TOP_HEIGHT_MIN, WORK_SURFACE_TOP_HEIGHT_MAX);
981 s.work_surface_side_width = s.work_surface_side_width.clamp(26, 80);
982 s.inline_diffs = normalize_inline_diffs(&s.inline_diffs).to_string();
983 s.synchronized_output =
984 normalize_synchronized_output(&s.synchronized_output).to_string();
985 s.locale = normalize_configured_locale(&s.locale)
986 .unwrap_or("en")
987 .to_string();
988 s.background_color = normalize_optional_background_color(s.background_color.as_deref());
989 s.theme = normalize_settings_theme(&s.theme);
990 s.default_model = s.default_model.as_deref().and_then(normalize_default_model);
991 s.reasoning_effort = s
992 .reasoning_effort
993 .as_deref()
994 .and_then(|value| normalize_reasoning_effort_setting(value).ok().flatten());
995 s.permission_posture = s
996 .permission_posture
997 .as_deref()
998 .and_then(normalize_permission_posture);
999 if legacy_yolo_default && s.permission_posture.is_none() {
1000 s.permission_posture = Some("full-access".to_string());
1001 }
1002 s.sandbox_mode = s.sandbox_mode.as_deref().and_then(normalize_sandbox_mode);
1003 s
1004 };
1005 if migrate_legacy_file {
1006 migrate_settings_file_to_primary_if_needed(&write_path, &read_path);
1007 }
1008 // One store: fold the dead `tui.toml` in and say what happened.
1009 if let Some(prefs_path) = tui_prefs_path.filter(|path| path.exists())
1010 && let Some(receipt) = fold_tui_prefs(
1011 &mut settings,
1012 &explicit_keys,
1013 &prefs_path,
1014 migrate_legacy_file,
1015 )
1016 {
1017 if migrate_legacy_file && !receipt.folded.is_empty() {
1018 // The fold is only real once settings.toml owns the value;
1019 // otherwise the next launch would read the moved-aside file's
1020 // theme back out of nothing and quietly lose it.
1021 if let Err(error) = settings.save_to_path(&write_path) {
1022 tracing::warn!(
1023 "failed to persist folded tui.toml values to {}: {error:#}",
1024 write_path.display()
1025 );
1026 }
1027 }
1028 if !receipt.is_empty() {
1029 settings.tui_prefs_migration = Some(receipt);
1030 }
1031 }
1032 // The provenance ledger: keys the document named itself came from
1033 // user config; everything else is the schema default until `set()`,
1034 // a CLI flag, or a higher layer says otherwise.
1035 for key in &explicit_keys {
1036 settings.provenance.insert(key.clone(), Layer::UserConfig);
1037 }
1038 Ok(settings)
1039 }
1040
1041 /// Whether this load normalized a legacy `default_mode = "yolo"` value.
1042 ///
1043 /// This is migration provenance, not a user-facing mode. New writes accept
1044 /// only Agent or Plan and serialize the independent permission posture.
1045 pub(crate) fn legacy_yolo_default_detected(&self) -> bool {
1046 self.legacy_yolo_default
1047 }
1048
1049 /// Receipt for the one-time `tui.toml` fold, when this load performed one.
1050 pub(crate) fn tui_prefs_migration(&self) -> Option<&TuiPrefsMigration> {
1051 self.tui_prefs_migration.as_ref()
1052 }
1053
1054 /// Which layer supplied the in-force value of `key`: the load ledger,
1055 /// defaulting to [`Layer::Default`] for keys the document never named.
1056 /// Aliases resolve to their canonical key first, so `/set collapse`
1057 /// reports the same layer as the `tool_collapse` row.
1058 pub(crate) fn provenance(&self, key: &str) -> Layer {
1059 let canonical = Self::canonical_key(key).unwrap_or(key);
1060 self.provenance
1061 .get(canonical)
1062 .copied()
1063 .unwrap_or(Layer::Default)
1064 }
1065
1066 /// The persisted field name behind a canonical schema key. Two schema
1067 /// keys predate their persisted names and cannot be renamed without a
1068 /// settings.toml migration.
1069 fn persisted_field_name(canonical: &str) -> &str {
1070 match canonical {
1071 "tool_collapse" => "tool_collapse_mode",
1072 "max_history" => "max_input_history",
1073 other => other,
1074 }
1075 }
1076
1077 /// The value `key` currently holds in this store, in its written-to-disk
1078 /// string form — `None` when the key is not a field of this store.
1079 ///
1080 /// `Settings` serializes field-for-field to settings.toml, so a document
1081 /// lookup on the serialized form shares `set`'s key vocabulary instead
1082 /// of growing a second hand-keyed reader beside it.
1083 pub fn value(&self, key: &str) -> Option<String> {
1084 let canonical = Self::canonical_key(key).unwrap_or(key);
1085 let field = Self::persisted_field_name(canonical);
1086 let document = toml::Value::try_from(self).ok()?;
1087 let value = document.as_table()?.get(field)?;
1088 Some(match value {
1089 toml::Value::String(text) => text.clone(),
1090 other => other.to_string(),
1091 })
1092 }
1093
1094 /// Whether the loaded settings document explicitly named `key` — a
1095 /// persisted user choice rather than an inherited default.
1096 pub fn is_set(&self, key: &str) -> bool {
1097 let canonical = Self::canonical_key(key).unwrap_or(key);
1098 self.provenance
1099 .get(Self::persisted_field_name(canonical))
1100 .is_some_and(|layer| *layer == Layer::UserConfig)
1101 }
1102
1103 /// Whether the user explicitly persisted an auto-compaction preference.
1104 /// A threshold is intent to enable compaction unless an explicit boolean
1105 /// says otherwise. When all three keys are absent, callers may choose a
1106 /// model-aware default.
1107 pub fn auto_compact_explicitly_configured() -> bool {
1108 let candidates = settings_path_candidates();
1109 #[cfg(test)]
1110 {
1111 crate::test_support::with_test_state_io_lock(|| {
1112 auto_compact_explicitly_configured_from_candidates(candidates)
1113 })
1114 }
1115 #[cfg(not(test))]
1116 auto_compact_explicitly_configured_from_candidates(candidates)
1117 }
1118 }
1119
1120 fn auto_compact_explicitly_configured_from_candidates(
1121 (primary, legacy_home, legacy_config_dir): (Option<PathBuf>, Option<PathBuf>, Option<PathBuf>),
1122 ) -> bool {
1123 let Ok(path) = resolve_settings_path_from_candidates(primary, legacy_home, legacy_config_dir)
1124 else {
1125 return false;
1126 };
1127 let Ok(content) = std::fs::read_to_string(path) else {
1128 return false;
1129 };
1130 let Ok(value) = toml::from_str::<toml::Value>(&content) else {
1131 return false;
1132 };
1133 auto_compact_explicitly_configured_in_document(&value)
1134 }
1135
1136 fn auto_compact_explicitly_configured_in_document(value: &toml::Value) -> bool {
1137 value.as_table().is_some_and(|table| {
1138 table.contains_key("auto_compact")
1139 || table.contains_key("auto_compact_threshold")
1140 || table.contains_key("auto_compact_threshold_percent")
1141 })
1142 }
1143
1144 /// The runtime overlay that forces `low_motion` on, when one wins over the
1145 /// persisted value. Mirrors the precedence of
1146 /// [`Settings::apply_env_overrides`] so a settings surface can name the real
1147 /// owner instead of calling a forced value "saved".
1148 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1149 pub enum MotionOverride {
1150 NoAnimationsEnv,
1151 VsCodeTerminal,
1152 TermiusTerminal,
1153 SshSession,
1154 TabbyTerminal,
1155 LegacyWindowsConsole,
1156 }
1157
1158 impl MotionOverride {
1159 /// The literal token a person can look for in their environment.
1160 #[must_use]
1161 pub fn label(self) -> &'static str {
1162 match self {
1163 Self::NoAnimationsEnv => "NO_ANIMATIONS",
1164 Self::VsCodeTerminal => "TERM_PROGRAM=vscode",
1165 Self::TermiusTerminal => "TERM_PROGRAM=Termius",
1166 Self::SshSession => "SSH_CLIENT/SSH_TTY",
1167 Self::TabbyTerminal => "TERM_PROGRAM=tabby",
1168 Self::LegacyWindowsConsole => "legacy Windows console",
1169 }
1170 }
1171
1172 /// Whether the override comes from the environment (a variable or an
1173 /// SSH session) rather than from the terminal program itself.
1174 #[must_use]
1175 pub fn is_environment(self) -> bool {
1176 matches!(self, Self::NoAnimationsEnv | Self::SshSession)
1177 }
1178 }
1179
1180 /// Detect which runtime overlay forces `low_motion`, in the order
1181 /// [`Settings::apply_env_overrides`] applies them.
1182 #[must_use]
1183 pub fn detect_low_motion_override() -> Option<MotionOverride> {
1184 let env_nonempty = |name: &str| std::env::var_os(name).is_some_and(|v| !v.is_empty());
1185 if env_truthy("NO_ANIMATIONS") {
1186 return Some(MotionOverride::NoAnimationsEnv);
1187 }
1188 let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
1189 if term_program.eq_ignore_ascii_case("vscode") {
1190 return Some(MotionOverride::VsCodeTerminal);
1191 }
1192 if term_program == "Termius" {
1193 return Some(MotionOverride::TermiusTerminal);
1194 }
1195 if env_nonempty("SSH_CLIENT") || env_nonempty("SSH_TTY") {
1196 return Some(MotionOverride::SshSession);
1197 }
1198 if term_program.to_ascii_lowercase().contains("tabby") {
1199 return Some(MotionOverride::TabbyTerminal);
1200 }
1201 if detected_legacy_windows_console_host() {
1202 return Some(MotionOverride::LegacyWindowsConsole);
1203 }
1204 None
1205 }
1206
1207 impl Settings {
1208 /// Apply environment-driven overlays after disk load. Used for
1209 /// platform a11y signals that should ignore the user's saved
1210 /// preference (#450). The env values are consulted at startup;
1211 /// changing them mid-session has no effect because settings are
1212 /// only re-read on `Settings::load()`.
1213 pub fn apply_env_overrides(&mut self) {
1214 if env_truthy("NO_ANIMATIONS") {
1215 self.low_motion = true;
1216 self.fancy_animations = false;
1217 }
1218 // VS Code (TERM_PROGRAM=vscode, #1356) and a few VTE terminals
1219 // (#1470) produce visible flicker at 120 FPS. Cap their redraw rate.
1220 // VS Code's xterm.js renderer also needs decorative
1221 // motion disabled: the underwater chrome added substantially more
1222 // independently moving cells than the original #1356 fix covered.
1223 // Ghostty is deliberately absent from this 30 FPS compatibility lane.
1224 // Its synchronized GPU renderer gets a dedicated 60 FPS atmosphere
1225 // cap in display_refresh; putting it here made the restored truecolor
1226 // ocean visibly step even though the terminal could keep up.
1227 // Like NO_ANIMATIONS above, this unconditionally overrides any
1228 // disk-loaded value — consistent precedence: env signals always win.
1229 let term_program = std::env::var("TERM_PROGRAM")
1230 .unwrap_or_default()
1231 .to_ascii_lowercase();
1232 // Tabby renders through Electron/xterm.js. Its Windows IME bridge
1233 // can observe cursor-positioning sequences while a frame is still
1234 // being applied, so use the calmer rendering path there.
1235 let term_is_tabby = term_program.contains("tabby");
1236 let term_constrains_frame_rate = term_program == "vscode";
1237 let vte_env_constrains_frame_rate = std::env::var_os("TILIX_ID")
1238 .is_some_and(|v| !v.is_empty())
1239 || std::env::var_os("TERMINATOR_UUID").is_some_and(|v| !v.is_empty());
1240 if term_constrains_frame_rate || vte_env_constrains_frame_rate {
1241 self.constrained_frame_rate = true;
1242 }
1243 if term_program == "vscode" {
1244 self.low_motion = true;
1245 self.fancy_animations = false;
1246 }
1247
1248 // Termius (TERM_PROGRAM=Termius) and SSH sessions exhibit the
1249 // same 120-FPS flicker class as VS Code — the SSH round-trip
1250 // races ahead of what the remote renderer can flush, so rapid
1251 // cursor-positioning sequences cycle through input boxes.
1252 // Drop both to the 30 FPS low-motion cap. Harvested from
1253 // PR #1479 by @CrepuscularIRIS / autoghclaw (closes #1433).
1254 //
1255 // SSH_CLIENT is exported by sshd for every TCP SSH session;
1256 // SSH_TTY is exported only for interactive PTY logins, so we
1257 // check both so non-PTY-allocating tools (rsync wrappers, etc.)
1258 // still pick this up if they end up running the TUI.
1259 let term_is_termius = std::env::var("TERM_PROGRAM").as_deref() == Ok("Termius");
1260 let in_ssh_session = std::env::var_os("SSH_CLIENT").is_some_and(|v| !v.is_empty())
1261 || std::env::var_os("SSH_TTY").is_some_and(|v| !v.is_empty());
1262 if term_is_termius || in_ssh_session {
1263 self.low_motion = true;
1264 self.fancy_animations = false;
1265 }
1266 if term_is_tabby {
1267 self.low_motion = true;
1268 self.fancy_animations = false;
1269 self.constrained_frame_rate = true;
1270 if self.synchronized_output.eq_ignore_ascii_case("auto") {
1271 self.synchronized_output = "off".to_string();
1272 }
1273 }
1274
1275 // Multiplexers need a bounded redraw rate, not a different product.
1276 // Preserve authored motion and let the frame limiter protect tmux /
1277 // screen; NO_ANIMATIONS remains the explicit hard-off contract.
1278 let in_terminal_multiplexer = std::env::var_os("TMUX").is_some_and(|v| !v.is_empty())
1279 || std::env::var_os("STY").is_some_and(|v| !v.is_empty());
1280 if in_terminal_multiplexer {
1281 self.constrained_frame_rate = true;
1282 }
1283
1284 // Plain Windows PowerShell / cmd.exe under legacy ConHost exposes none
1285 // of the modern terminal markers below. Keep rendering calmer there:
1286 // lower the motion rate, disable animated chrome, and avoid DEC 2026
1287 // synchronized-output wrapping unless the user explicitly forced it on.
1288 if detected_legacy_windows_console_host() {
1289 self.low_motion = true;
1290 self.fancy_animations = false;
1291 if self.synchronized_output.eq_ignore_ascii_case("auto") {
1292 self.synchronized_output = "off".to_string();
1293 }
1294 }
1295
1296 // Ptyxis 50.x (the new default terminal on Ubuntu 26.04) ships with
1297 // VTE 0.84.x which mishandles DEC mode 2026 synchronized output: the
1298 // begin/end pair is parsed but each wrapped frame still triggers a
1299 // full-viewport flash on the GPU compositor side, so any TUI that
1300 // uses DEC 2026 to avoid tearing instead gets visible flicker on
1301 // every redraw. gnome-terminal 3.58 on the same VTE renders cleanly,
1302 // so we can't broaden the opt-out to all VTE-based terminals —
1303 // only the Ptyxis-specific signals trigger it. Confirmed
1304 // user-visible regression starting with Ubuntu 26.04's default
1305 // terminal swap; cargo-installed binaries are not exempt because
1306 // the bug is in the terminal, not the binary.
1307 //
1308 // Only flip `auto` to `off`; respect an explicit `"on"` so users
1309 // who upgrade Ptyxis or want to confirm the fix landed upstream
1310 // can override the heuristic from the persisted settings.toml or
1311 // `/set synchronized_output on`.
1312 if self.synchronized_output.eq_ignore_ascii_case("auto") && detected_ptyxis_terminal() {
1313 self.synchronized_output = "off".to_string();
1314 }
1315 }
1316
1317 /// Run one atomic load → mutate → save cycle against `settings.toml`.
1318 ///
1319 /// **Every writer that reads the whole file, changes some fields, and writes
1320 /// the whole file back must go through here** (or through
1321 /// [`SettingsTransaction`] for the multi-step shape). `save` serializes the
1322 /// complete struct, so two unsynchronized writers that each did their own
1323 /// `load_persisted` will each write back the *other's* pre-image: whichever
1324 /// saves last silently reverts the other's field. Locking `save` alone does
1325 /// not help, because the stale read already happened before the lock.
1326 ///
1327 /// Two locks are taken (see [`with_settings_transaction`]): a process-wide
1328 /// mutex keyed by the resolved settings path, which covers writers that
1329 /// never share an object — a background startup-default drain and a
1330 /// synchronous Shift+Tab permission write, the concrete pair that lost
1331 /// `default_mode` / `permission_posture` against each other — and a
1332 /// cross-process file lock, which covers a second Codewhale process on the
1333 /// same home directory.
1334 ///
1335 /// The closure must not call `transact`, [`with_settings_transaction`],
1336 /// `save`, or `load_persisted` itself — the lock is not re-entrant. Use
1337 /// [`with_settings_transaction`] when you need more than one save in one
1338 /// critical section.
1339 pub fn transact<T>(mutate: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
1340 with_settings_transaction(|transaction| {
1341 let mut settings = transaction.load()?;
1342 let value = mutate(&mut settings)?;
1343 transaction.save(&settings)?;
1344 Ok(value)
1345 })
1346 }
1347
1348 /// [`Self::transact`] for a mutation that may decide there is nothing to
1349 /// write. Returning `None` abandons the transaction without touching disk,
1350 /// so a "flag already set" early return does not rewrite the file.
1351 pub fn transact_opt<T>(
1352 mutate: impl FnOnce(&mut Self) -> Result<Option<T>>,
1353 ) -> Result<Option<T>> {
1354 with_settings_transaction(|transaction| {
1355 let mut settings = transaction.load()?;
1356 let Some(value) = mutate(&mut settings)? else {
1357 return Ok(None);
1358 };
1359 transaction.save(&settings)?;
1360 Ok(Some(value))
1361 })
1362 }
1363
1364 /// Save settings to disk as a standalone, fully locked write.
1365 ///
1366 /// Prefer [`Self::transact`]: calling this on a `Settings` that was loaded
1367 /// outside a transaction writes back a snapshot that may already be stale
1368 /// for every field the caller did *not* mean to change. This entry point
1369 /// still takes both locks, so the bytes it writes are never interleaved with
1370 /// another writer's — it just cannot fix a stale read that already happened.
1371 ///
1372 /// Not callable from inside a transaction: the cross-process lock is not
1373 /// re-entrant, so a nested acquisition would deadlock against itself. Inside
1374 /// a critical section use [`SettingsTransaction::save`].
1375 #[cfg(test)]
1376 pub fn save(&self) -> Result<()> {
1377 with_settings_transaction(|transaction| transaction.save(self))
1378 }
1379
1380 /// The write half of a settings transaction: serialize, merge comments, and
1381 /// replace the file atomically. The caller already holds both the
1382 /// process-wide mutex and the cross-process file lock.
1383 fn save_locked(&self, path: &Path) -> Result<()> {
1384 #[cfg(test)]
1385 {
1386 crate::test_support::with_test_state_io_lock(|| self.save_to_path(path))
1387 }
1388 #[cfg(not(test))]
1389 self.save_to_path(path)
1390 }
1391
1392 fn save_to_path(&self, path: &Path) -> Result<()> {
1393 // Parse-error fallback values keep the UI usable, but cannot replace
1394 // the unreadable document. Do not echo its potentially private text.
1395 anyhow::ensure!(self.load_error.is_none(), "settings.toml: invalid TOML");
1396 // Create config directory if it doesn't exist
1397 if let Some(parent) = path.parent() {
1398 std::fs::create_dir_all(parent).with_context(|| {
1399 format!("Failed to create config directory {}", parent.display())
1400 })?;
1401 }
1402
1403 let mut serialized =
1404 toml::to_string_pretty(self).context("Failed to serialize settings")?;
1405 if !self.auto_compact_explicit {
1406 let mut document = serialized
1407 .parse::<toml_edit::DocumentMut>()
1408 .context("Failed to prepare settings for persistence")?;
1409 document.remove("auto_compact");
1410 document.remove("auto_compact_threshold_percent");
1411 serialized = document.to_string();
1412 }
1413 let body = if path.exists() {
1414 let raw = std::fs::read_to_string(path)
1415 .with_context(|| format!("Failed to read settings at {}", path.display()))?;
1416 codewhale_config::merge_and_preserve_comments(&serialized, &raw).unwrap_or_else(|e| {
1417 tracing::warn!("failed to merge settings comments, saving without them: {e:#}");
1418 serialized
1419 })
1420 } else {
1421 serialized
1422 };
1423 atomically_replace_settings_file(path, body.as_bytes())
1424 }
1425
1426 /// Set a single setting by key
1427 /// Canonical schema key for a `set()` spelling: the first pattern of each
1428 /// match arm below. `None` means `set()` rejects the spelling, so the
1429 /// ledger never learns it. Keep in sync with the arms — the
1430 /// `set_marks_session_provenance` test enforces it per spelling.
1431 pub(crate) fn canonical_key(key: &str) -> Option<&'static str> {
1432 Some(match key {
1433 "auto_compact" | "compact" => "auto_compact",
1434 "auto_compact_threshold" | "auto_compact_threshold_percent" => {
1435 "auto_compact_threshold_percent"
1436 }
1437 "calm_mode" | "calm" => "calm_mode",
1438 "tool_collapse" | "tool_collapse_mode" | "collapse" => "tool_collapse",
1439 "low_motion" | "motion" => "low_motion",
1440 "fancy_animations" | "fancy" | "animations" => "fancy_animations",
1441 "focus_texture" | "texture" => "focus_texture",
1442 "work_surface_placement" | "work_surface" | "work_rail" => "work_surface_placement",
1443 "rail_panel" | "rail" => "rail_panel",
1444 "work_surface_top_height" | "work_top_height" => "work_surface_top_height",
1445 "work_surface_side_width" | "work_side_width" => "work_surface_side_width",
1446 "bracketed_paste" | "paste" => "bracketed_paste",
1447 "paste_burst_detection" | "paste_burst" => "paste_burst_detection",
1448 "mention_menu_limit" | "mention_limit" => "mention_menu_limit",
1449 "mention_walk_depth" | "mention_depth" | "completions_walk_depth" => {
1450 "mention_walk_depth"
1451 }
1452 "mention_menu_behavior" | "mention_behavior" | "mention_menu" => {
1453 "mention_menu_behavior"
1454 }
1455 "show_thinking" | "thinking" => "show_thinking",
1456 "thinking_default_expanded" | "thinking_expanded" => "thinking_default_expanded",
1457 "thinking_preview_lines" | "thinking_preview" => "thinking_preview_lines",
1458 "thinking_highlight" | "reasoning_highlight" => "thinking_highlight",
1459 "help_expand_groups" | "help_expanded" => "help_expand_groups",
1460 "contextual_tips" => "contextual_tips",
1461 "pin_last_prompt" | "pin_prompt" => "pin_last_prompt",
1462 "show_tool_details" | "tool_details" => "show_tool_details",
1463 "inline_diffs" | "inline_diff" | "diffs" => "inline_diffs",
1464 "locale" | "language" => "locale",
1465 "theme" | "ui_theme" => "theme",
1466 "background_color" | "background" | "bg" => "background_color",
1467 "composer_density" | "composer" => "composer_density",
1468 "composer_border" | "border" => "composer_border",
1469 "composer_multiline_mode" | "multiline_mode" | "multiline" => "composer_multiline_mode",
1470 "composer_vim_mode" | "vim_mode" | "vim" => "composer_vim_mode",
1471 "transcript_spacing" | "spacing" => "transcript_spacing",
1472 "status_indicator" | "indicator" => "status_indicator",
1473 "synchronized_output" | "sync_output" | "sync" => "synchronized_output",
1474 "workspace_follow_symlinks" | "follow_symlinks" => "workspace_follow_symlinks",
1475 "default_mode" | "mode" => "default_mode",
1476 "context_panel" | "context" | "session_panel" => "context_panel",
1477 "sessions_rail" | "sessions_panel" | "session_rail" => "sessions_rail",
1478 "session_auto_resume" | "auto_resume" => "session_auto_resume",
1479 "cost_currency" | "currency" => "cost_currency",
1480 "max_history" | "history" => "max_history",
1481 "default_model" | "model" => "default_model",
1482 "reasoning_effort" | "effort" => "reasoning_effort",
1483 "permission_posture" | "permissions" => "permission_posture",
1484 "sandbox_mode" | "sandbox" | "filesystem_sandbox" => "sandbox_mode",
1485 _ => return None,
1486 })
1487 }
1488
1489 pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
1490 // The ledger learns the canonical key only when the write below
1491 // succeeds: a rejected value leaves the previous layer in force.
1492 let canonical = Self::canonical_key(key);
1493 match key {
1494 "auto_compact" | "compact" => {
1495 self.auto_compact = parse_bool(value)?;
1496 self.auto_compact_explicit = true;
1497 }
1498 "auto_compact_threshold" | "auto_compact_threshold_percent" => {
1499 self.auto_compact_threshold_percent =
1500 parse_percent_setting("auto_compact_threshold_percent", value)?;
1501 self.auto_compact = true;
1502 self.auto_compact_explicit = true;
1503 }
1504 "calm_mode" | "calm" => {
1505 self.calm_mode = parse_bool(value)?;
1506 }
1507 "tool_collapse" | "tool_collapse_mode" | "collapse" => {
1508 let normalized = normalize_tool_collapse_mode(value);
1509 if !matches!(normalized, "compact" | "expanded" | "calm") {
1510 return Err(anyhow::anyhow!(
1511 "Failed to update setting: invalid tool collapse mode '{value}'. Expected: compact, expanded, or calm."
1512 ));
1513 }
1514 self.tool_collapse_mode = normalized.to_string();
1515 }
1516 "low_motion" | "motion" => {
1517 self.low_motion = parse_bool(value)?;
1518 }
1519 "fancy_animations" | "fancy" | "animations" => {
1520 self.fancy_animations = parse_bool(value)?;
1521 }
1522 "focus_texture" | "texture" => {
1523 let normalized = value.trim().to_ascii_lowercase();
1524 if !matches!(normalized.as_str(), "off" | "scrim" | "grain") {
1525 anyhow::bail!(
1526 "Failed to update setting: invalid focus texture '{value}'. Expected: off, scrim, or grain."
1527 );
1528 }
1529 self.focus_texture = normalized;
1530 }
1531 "work_surface_placement" | "work_surface" | "work_rail" => {
1532 let normalized = value.trim().to_ascii_lowercase();
1533 if !matches!(
1534 normalized.as_str(),
1535 "top" | "bottom" | "left" | "right" | "off"
1536 ) {
1537 anyhow::bail!(
1538 "Failed to update setting: invalid work surface placement '{value}'. Expected: top, bottom, left, right, or off."
1539 );
1540 }
1541 self.work_surface_placement = normalized;
1542 }
1543 "rail_panel" | "rail" => {
1544 let normalized = value.trim().to_ascii_lowercase();
1545 // `pinned` stays accepted as a setting word; it folds into
1546 // the tasks view exactly like the load-time migration.
1547 if !matches!(
1548 normalized.as_str(),
1549 "tasks"
1550 | "agents"
1551 | "background"
1552 | "files"
1553 | "notepad"
1554 | "context"
1555 | "git"
1556 | "price"
1557 | "watch"
1558 | "pinned"
1559 ) {
1560 anyhow::bail!(
1561 "Failed to update setting: invalid workbar panel '{value}'. Expected: tasks, agents, background, files, notepad, context, git, or price."
1562 );
1563 }
1564 self.rail_panel = normalize_rail_panel(&normalized).to_string();
1565 self.rail_panel_explicit = true;
1566 }
1567 "work_surface_top_height" | "work_top_height" => {
1568 self.work_surface_top_height = parse_u16_range(
1569 "work_surface_top_height",
1570 value,
1571 WORK_SURFACE_TOP_HEIGHT_MIN,
1572 WORK_SURFACE_TOP_HEIGHT_MAX,
1573 )?;
1574 }
1575 "work_surface_side_width" | "work_side_width" => {
1576 self.work_surface_side_width =
1577 parse_u16_range("work_surface_side_width", value, 26, 80)?;
1578 }
1579 "bracketed_paste" | "paste" => {
1580 self.bracketed_paste = parse_bool(value)?;
1581 }
1582 "paste_burst_detection" | "paste_burst" => {
1583 self.paste_burst_detection = parse_bool(value)?;
1584 }
1585 "mention_menu_limit" | "mention_limit" => {
1586 self.mention_menu_limit = parse_usize_setting("mention_menu_limit", value)?;
1587 }
1588 "mention_walk_depth" | "mention_depth" | "completions_walk_depth" => {
1589 self.mention_walk_depth = parse_usize_setting("mention_walk_depth", value)?;
1590 }
1591 "mention_menu_behavior" | "mention_behavior" | "mention_menu" => {
1592 self.mention_menu_behavior = normalize_mention_menu_behavior(value)?;
1593 }
1594 "show_thinking" | "thinking" => {
1595 self.show_thinking = parse_bool(value)?;
1596 }
1597 "thinking_default_expanded" | "thinking_expanded" => {
1598 self.thinking_default_expanded = parse_bool(value)?;
1599 }
1600 "thinking_preview_lines" | "thinking_preview" => {
1601 self.thinking_preview_lines =
1602 parse_usize_setting("thinking_preview_lines", value)?.min(40);
1603 }
1604 "thinking_highlight" | "reasoning_highlight" => {
1605 self.thinking_highlight = parse_bool(value)?;
1606 }
1607 "help_expand_groups" | "help_expanded" => {
1608 self.help_expand_groups = parse_bool(value)?;
1609 }
1610 "contextual_tips" => {
1611 self.contextual_tips = parse_bool(value)?;
1612 }
1613 "pin_last_prompt" | "pin_prompt" => {
1614 self.pin_last_prompt = parse_bool(value)?;
1615 }
1616 "show_tool_details" | "tool_details" => {
1617 self.show_tool_details = parse_bool(value)?;
1618 }
1619 "inline_diffs" | "inline_diff" | "diffs" => {
1620 let normalized = value.trim().to_ascii_lowercase();
1621 if !matches!(normalized.as_str(), "full" | "summary" | "off") {
1622 anyhow::bail!(
1623 "Failed to update setting: invalid inline diff mode '{value}'. Expected: full, summary, or off."
1624 );
1625 }
1626 self.inline_diffs = normalized;
1627 }
1628 "locale" | "language" => {
1629 let Some(locale) = normalize_configured_locale(value) else {
1630 anyhow::bail!(
1631 "Failed to update setting: invalid locale '{value}'. Expected: {}.",
1632 codewhale_localization::configured_locale_values(", ")
1633 );
1634 };
1635 self.locale = locale.to_string();
1636 }
1637 "theme" | "ui_theme" => {
1638 self.theme = normalize_theme_setting(value).map_err(anyhow::Error::msg)?;
1639 }
1640 "background_color" | "background" | "bg" => {
1641 self.background_color = normalize_background_color_setting(value)?;
1642 }
1643 "composer_density" | "composer" => {
1644 let normalized = normalize_composer_density(value);
1645 if !["compact", "comfortable", "spacious"].contains(&normalized) {
1646 anyhow::bail!(
1647 "Failed to update setting: invalid composer density '{value}'. Expected: compact, comfortable, spacious."
1648 );
1649 }
1650 self.composer_density = normalized.to_string();
1651 }
1652 "composer_border" | "border" => {
1653 self.composer_border = parse_bool(value)?;
1654 }
1655 "composer_multiline_mode" | "multiline_mode" | "multiline" => {
1656 self.composer_multiline_mode = parse_bool(value)?;
1657 }
1658 "composer_vim_mode" | "vim_mode" | "vim" => {
1659 let normalized = value.trim().to_ascii_lowercase();
1660 if !["vim", "normal"].contains(&normalized.as_str()) {
1661 anyhow::bail!(
1662 "Failed to update setting: invalid composer vim mode '{value}'. Expected: normal, vim."
1663 );
1664 }
1665 self.composer_vim_mode = normalized;
1666 }
1667 "transcript_spacing" | "spacing" => {
1668 let normalized = normalize_transcript_spacing(value);
1669 if !["compact", "comfortable", "spacious"].contains(&normalized) {
1670 anyhow::bail!(
1671 "Failed to update setting: invalid transcript spacing '{value}'. Expected: compact, comfortable, spacious."
1672 );
1673 }
1674 self.transcript_spacing = normalized.to_string();
1675 }
1676 "status_indicator" | "indicator" => {
1677 let normalized = normalize_status_indicator(value);
1678 if !["cw", "whale", "dots", "off"].contains(&normalized) {
1679 anyhow::bail!(
1680 "Failed to update setting: invalid status indicator '{value}'. Expected: cw, whale, dots, off."
1681 );
1682 }
1683 self.status_indicator = normalized.to_string();
1684 }
1685 "synchronized_output" | "sync_output" | "sync" => {
1686 let normalized = normalize_synchronized_output(value);
1687 if !["auto", "on", "off"].contains(&normalized) {
1688 anyhow::bail!(
1689 "Failed to update setting: invalid synchronized_output '{value}'. Expected: auto, on, off."
1690 );
1691 }
1692 self.synchronized_output = normalized.to_string();
1693 }
1694 "workspace_follow_symlinks" | "follow_symlinks" => {
1695 self.workspace_follow_symlinks = parse_bool(value)?;
1696 }
1697 "default_mode" | "mode" => {
1698 // Act (wire: agent), Plan, and Operate are valid startup modes.
1699 // yolo remains a permission-migration alias, not a mode write.
1700 self.default_mode = match value.trim().to_ascii_lowercase().as_str() {
1701 "agent" | "normal" | "act" | "work" | "edit" => "agent".to_string(),
1702 "plan" => "plan".to_string(),
1703 "operate" | "operation" | "ops" => "operate".to_string(),
1704 _ => anyhow::bail!(
1705 "Failed to update setting: invalid mode '{value}'. Expected: act (agent), plan, or operate."
1706 ),
1707 };
1708 }
1709 "context_panel" | "context" | "session_panel" => {
1710 self.context_panel = parse_bool(value)?;
1711 }
1712 "sessions_rail" | "sessions_panel" | "session_rail" => {
1713 self.sessions_rail = parse_bool(value)?;
1714 }
1715 "session_auto_resume" | "auto_resume" => {
1716 self.session_auto_resume = parse_bool(value)?;
1717 }
1718 "cost_currency" | "currency" => {
1719 let Some(currency) = crate::pricing::CostCurrency::from_setting(value) else {
1720 anyhow::bail!(
1721 "Failed to update setting: invalid cost currency '{value}'. Expected: usd, cny, rmb, yuan."
1722 );
1723 };
1724 self.cost_currency = match currency {
1725 crate::pricing::CostCurrency::Usd => "usd",
1726 crate::pricing::CostCurrency::Cny => "cny",
1727 }
1728 .to_string();
1729 }
1730 "max_history" | "history" => {
1731 let max: usize = value.parse().map_err(|_| {
1732 anyhow::anyhow!(
1733 "Failed to update setting: invalid max history '{value}'. Expected a positive number."
1734 )
1735 })?;
1736 self.max_input_history = max;
1737 }
1738 "default_model" | "model" => {
1739 anyhow::bail!(
1740 "Model defaults belong to config.toml. Use /model and choose Remember as my default, or /config model <id> --save."
1741 );
1742 }
1743 "reasoning_effort" | "effort" => {
1744 self.reasoning_effort = normalize_reasoning_effort_setting(value)?;
1745 }
1746 "permission_posture" | "permissions" => {
1747 self.permission_posture = normalize_permission_posture(value);
1748 if self.permission_posture.is_none() {
1749 anyhow::bail!(
1750 "Failed to update setting: invalid permission posture '{value}'. Expected: ask, auto-review, or full-access."
1751 );
1752 }
1753 }
1754 "sandbox_mode" | "sandbox" | "filesystem_sandbox" => {
1755 self.sandbox_mode = normalize_sandbox_mode(value);
1756 if self.sandbox_mode.is_none() {
1757 anyhow::bail!(
1758 "Failed to update setting: invalid sandbox_mode '{value}'. Expected: read-only, workspace-write, danger-full-access, or external-sandbox."
1759 );
1760 }
1761 }
1762 _ => {
1763 anyhow::bail!("Failed to update setting: unknown setting '{key}'.");
1764 }
1765 }
1766 if let Some(canonical) = canonical {
1767 self.provenance
1768 .insert(canonical.to_string(), Layer::SessionOverride);
1769 }
1770 Ok(())
1771 }
1772
1773 /// Apply a named settings preset (#3478).
1774 ///
1775 /// Presets are the first bundled-settings mechanism: a single name applies a
1776 /// coherent group of presentation knobs. `calm` is the "beautiful/calm
1777 /// transcript" preset — it quiets motion and verbose tool output while
1778 /// **keeping evidence reachable**: thinking stays visible and tool runs stay
1779 /// expandable (only their inline detail is collapsed), so maintainer/release
1780 /// work is never blind to failures. Presentation only — no model, provider,
1781 /// routing, or safety setting is touched. Reuses [`Settings::set`] so each
1782 /// field goes through the same validation as a single-key set.
1783 ///
1784 /// Returns the keys changed, or an error for an unknown preset.
1785 pub fn apply_preset(&mut self, name: &str) -> Result<Vec<&'static str>> {
1786 let Some(bundle) = preset_fields(name) else {
1787 anyhow::bail!("Unknown preset '{}'. Available presets: calm", name.trim());
1788 };
1789 let mut changed = Vec::with_capacity(bundle.len());
1790 for (key, value) in bundle {
1791 self.set(key, value)?;
1792 changed.push(*key);
1793 }
1794 Ok(changed)
1795 }
1796
1797 /// Get all settings as a displayable string
1798 pub fn display(&self, locale: codewhale_localization::Locale) -> String {
1799 use codewhale_localization::{MessageId, tr};
1800 let mut lines = Vec::new();
1801 lines.push(tr(locale, MessageId::SettingsTitle).to_string());
1802 lines.push("─────────────────────────────".to_string());
1803 lines.push(format!(" auto_compact: {}", self.auto_compact));
1804 lines.push(format!(
1805 " auto_compact_pct: {:.0}",
1806 self.auto_compact_threshold_percent
1807 ));
1808 lines.push(format!(" calm_mode: {}", self.calm_mode));
1809 lines.push(format!(" tool_collapse: {}", self.tool_collapse_mode));
1810 lines.push(format!(" low_motion: {}", self.low_motion));
1811 lines.push(format!(" fancy_animations: {}", self.fancy_animations));
1812 lines.push(format!(" focus_texture: {}", self.focus_texture));
1813 lines.push(format!(
1814 " work_surface: {}",
1815 self.work_surface_placement
1816 ));
1817 lines.push(format!(
1818 " work_top_height: {}",
1819 self.work_surface_top_height
1820 ));
1821 lines.push(format!(
1822 " work_side_width: {}",
1823 self.work_surface_side_width
1824 ));
1825 lines.push(format!(" rail_panel: {}", self.rail_panel));
1826 lines.push(format!(" bracketed_paste: {}", self.bracketed_paste));
1827 lines.push(format!(
1828 " paste_burst_detect: {}",
1829 self.paste_burst_detection
1830 ));
1831 lines.push(format!(" mention_menu_limit: {}", self.mention_menu_limit));
1832 lines.push(format!(" mention_walk_depth: {}", self.mention_walk_depth));
1833 lines.push(format!(
1834 " mention_behavior: {}",
1835 self.mention_menu_behavior
1836 ));
1837 lines.push(format!(" show_thinking: {}", self.show_thinking));
1838 lines.push(format!(
1839 " thinking_expanded: {}",
1840 self.thinking_default_expanded
1841 ));
1842 lines.push(format!(
1843 " thinking_preview: {}",
1844 self.thinking_preview_lines
1845 ));
1846 lines.push(format!(" thinking_highlight: {}", self.thinking_highlight));
1847 lines.push(format!(
1848 " help_expand_groups: {}",
1849 self.help_expand_groups
1850 ));
1851 lines.push(format!(" pin_last_prompt: {}", self.pin_last_prompt));
1852 lines.push(format!(" contextual_tips: {}", self.contextual_tips));
1853 lines.push(format!(" show_tool_details: {}", self.show_tool_details));
1854 lines.push(format!(" inline_diffs: {}", self.inline_diffs));
1855 lines.push(format!(" locale: {}", self.locale));
1856 lines.push(format!(" theme: {}", self.theme));
1857 lines.push(format!(
1858 " background_color: {}",
1859 self.background_color.as_deref().unwrap_or("(default)")
1860 ));
1861 lines.push(format!(" composer_density: {}", self.composer_density));
1862 lines.push(format!(" composer_border: {}", self.composer_border));
1863 lines.push(format!(
1864 " composer_multiline_mode: {}",
1865 self.composer_multiline_mode
1866 ));
1867 lines.push(format!(" composer_vim_mode: {}", self.composer_vim_mode));
1868 lines.push(format!(" transcript_spacing: {}", self.transcript_spacing));
1869 lines.push(format!(" status_indicator: {}", self.status_indicator));
1870 lines.push(format!(
1871 " synchronized_output: {}",
1872 self.synchronized_output
1873 ));
1874 lines.push(format!(
1875 " workspace_follow_symlinks: {}",
1876 self.workspace_follow_symlinks
1877 ));
1878 lines.push(format!(" default_mode: {}", self.default_mode));
1879 lines.push(format!(" context_panel: {}", self.context_panel));
1880 lines.push(format!(" cost_currency: {}", self.cost_currency));
1881 lines.push(format!(" max_history: {}", self.max_input_history));
1882 lines.push(" model defaults: config.toml (use /config)".to_string());
1883 lines.push(format!(
1884 " reasoning_effort: {}",
1885 self.reasoning_effort
1886 .as_deref()
1887 .unwrap_or("(config/default)")
1888 ));
1889 lines.push(format!(
1890 " permission_posture: {}",
1891 self.permission_posture
1892 .as_deref()
1893 .unwrap_or("(config/default)")
1894 ));
1895 lines.push(format!(
1896 " sandbox_mode: {} # filesystem scope (not approval)",
1897 self.sandbox_mode.as_deref().unwrap_or("(config/default)")
1898 ));
1899 lines.push(String::new());
1900 lines.push(format!(
1901 "{} {}",
1902 tr(locale, MessageId::SettingsConfigFile),
1903 Self::path().map_or_else(|_| "(unknown)".to_string(), |p| p.display().to_string())
1904 ));
1905 // Provenance footer: which keys this load actually owns versus the
1906 // schema defaults, so `/settings` says what the user set. Session
1907 // marks only appear when the instance outlives a `set()` (the CLI
1908 // and editor transactions reload from disk).
1909 let mut user: Vec<&str> = Vec::new();
1910 let mut session: Vec<&str> = Vec::new();
1911 let mut keys: Vec<&str> = self.provenance.keys().map(String::as_str).collect();
1912 keys.sort_unstable();
1913 for key in keys {
1914 match self.provenance(key) {
1915 Layer::UserConfig => user.push(key),
1916 Layer::SessionOverride => session.push(key),
1917 Layer::ManagedPolicy | Layer::CliFlag | Layer::ProjectConfig | Layer::Default => {}
1918 }
1919 }
1920 if !user.is_empty() || !session.is_empty() {
1921 lines.push(String::new());
1922 if !user.is_empty() {
1923 lines.push(format!(" from settings.toml: {}", user.join(", ")));
1924 }
1925 if !session.is_empty() {
1926 lines.push(format!(" session override: {}", session.join(", ")));
1927 }
1928 }
1929 lines.join("\n")
1930 }
1931
1932 /// Toggle one exact provider/model pin without touching credentials or
1933 /// the provider's default route.
1934 pub fn toggle_pinned_model(&mut self, provider: &str, model: &str) -> bool {
1935 let provider = provider.trim();
1936 let model = model.trim();
1937 if provider.is_empty() || model.is_empty() || model.eq_ignore_ascii_case("auto") {
1938 return false;
1939 }
1940 if let Some(index) = self.pinned_models.iter().position(|pin| {
1941 pin.provider.eq_ignore_ascii_case(provider) && pin.model.eq_ignore_ascii_case(model)
1942 }) {
1943 self.pinned_models.remove(index);
1944 return false;
1945 }
1946 self.pinned_models.push(PinnedModel {
1947 provider: provider.to_string(),
1948 model: model.to_string(),
1949 label: None,
1950 });
1951 true
1952 }
1953
1954 #[allow(dead_code)] // label editing surface is exposed through settings serialization first
1955 pub fn set_pinned_model_label(
1956 &mut self,
1957 provider: &str,
1958 model: &str,
1959 label: Option<String>,
1960 ) -> bool {
1961 self.pinned_models
1962 .iter_mut()
1963 .find(|pin| {
1964 pin.provider.eq_ignore_ascii_case(provider) && pin.model.eq_ignore_ascii_case(model)
1965 })
1966 .map(|pin| {
1967 pin.label = label.filter(|value| !value.trim().is_empty());
1968 true
1969 })
1970 .unwrap_or(false)
1971 }
1972
1973 pub fn move_pinned_model(&mut self, provider: &str, model: &str, delta: isize) -> bool {
1974 let Some(index) = self.pinned_models.iter().position(|pin| {
1975 pin.provider.eq_ignore_ascii_case(provider) && pin.model.eq_ignore_ascii_case(model)
1976 }) else {
1977 return false;
1978 };
1979 let target = if delta.is_negative() {
1980 index.saturating_sub(delta.unsigned_abs())
1981 } else {
1982 index.saturating_add(delta as usize)
1983 };
1984 let target = target.min(self.pinned_models.len().saturating_sub(1));
1985 if target == index {
1986 return false;
1987 }
1988 let pin = self.pinned_models.remove(index);
1989 self.pinned_models.insert(target, pin);
1990 true
1991 }
1992
1993 /// Resolved boolean for whether the renderer should wrap each frame in
1994 /// DEC mode 2026 synchronized output. `auto` and `on` enable; `off`
1995 /// disables. The `auto` → `off` flip for known-bad terminals happens
1996 /// earlier in [`Self::apply_env_overrides`]; this method only inspects
1997 /// the final state.
1998 #[must_use]
1999 pub fn synchronized_output_enabled(&self) -> bool {
2000 !self.synchronized_output.eq_ignore_ascii_case("off")
2001 }
2002
2003 /// Runtime bracketed-paste mode after terminal-host quirks are applied.
2004 ///
2005 /// This deliberately does not mutate [`Settings::bracketed_paste`]:
2006 /// `apply_env_overrides()` can run before saving settings, and a legacy
2007 /// conhost runtime fallback must not permanently disable bracketed paste
2008 /// when the same config is later used in Windows Terminal or another
2009 /// modern terminal.
2010 #[must_use]
2011 pub fn effective_bracketed_paste(&self) -> bool {
2012 self.bracketed_paste && !detected_legacy_windows_console_host()
2013 }
2014 }
2015
2016 fn resolve_settings_path_from_candidates(
2017 primary: Option<PathBuf>,
2018 legacy_home: Option<PathBuf>,
2019 legacy_config_dir: Option<PathBuf>,
2020 ) -> Result<PathBuf> {
2021 if let Some(path) = primary.as_ref()
2022 && path.exists()
2023 {
2024 return Ok(path.clone());
2025 }
2026
2027 if let Some(path) = legacy_home
2028 && path.exists()
2029 {
2030 return Ok(path);
2031 }
2032
2033 if let Some(path) = legacy_config_dir.as_ref()
2034 && path.exists()
2035 {
2036 return Ok(path.clone());
2037 }
2038
2039 primary.or(legacy_config_dir).ok_or_else(|| {
2040 anyhow::anyhow!("Failed to resolve settings path: no config directory found.")
2041 })
2042 }
2043
2044 /// Proof that the caller is inside the settings critical section.
2045 ///
2046 /// Only [`with_settings_transaction`] can hand one out, so a `load`/`save` pair
2047 /// on this type is by construction covered by both the process-wide mutex and
2048 /// the cross-process file lock.
2049 pub(crate) struct SettingsTransaction {
2050 path: PathBuf,
2051 }
2052
2053 impl SettingsTransaction {
2054 /// Read the on-disk values inside the critical section.
2055 pub(crate) fn load(&self) -> Result<Settings> {
2056 Settings::load_persisted_locked()
2057 }
2058
2059 /// Write the whole file inside the critical section.
2060 pub(crate) fn save(&self, settings: &Settings) -> Result<()> {
2061 settings.save_locked(&self.path)
2062 }
2063 }
2064
2065 /// Run `operation` as one whole-file settings critical section.
2066 ///
2067 /// Most callers want [`Settings::transact`]. Reach for this directly only when a
2068 /// single logical change needs more than one save under one lock — the
2069 /// Shift+Tab root-policy release is the motivating case: it commits the new
2070 /// posture, unsets the shadowing root config key, and must restore the previous
2071 /// posture if that unset fails. Splitting that into two `transact` calls would
2072 /// let another writer observe (and rewrite over) the uncommitted middle state.
2073 ///
2074 /// Two locks are taken, in this order, and both are held across disk I/O:
2075 ///
2076 /// 1. A process-wide mutex keyed by the resolved settings path. It covers
2077 /// writers that never share an object — a background startup-default drain
2078 /// and a synchronous Shift+Tab permission write, the concrete pair that lost
2079 /// `default_mode` / `permission_posture` against each other.
2080 /// 2. An **advisory file lock on an adjacent `settings.toml.lock`**, following
2081 /// the `codewhale_config::config_document` pattern. The process mutex says
2082 /// nothing about a second Codewhale process (a second TUI, `codewhale exec`,
2083 /// the runtime HTTP surface in another instance) doing its own
2084 /// load/modify/save. Without a cross-process lock those two interleave and
2085 /// the later save reverts the earlier one's field — last-save-wins across
2086 /// processes, which is exactly the bug the in-process lock was added to
2087 /// prevent in-process.
2088 ///
2089 /// The lock file is only ever a lock: no settings content is written to it, so
2090 /// a stale one carries nothing to lose.
2091 ///
2092 /// There is exactly one permitted lock order for anything that touches
2093 /// `settings.toml`, and every acquisition in the tree below obeys it:
2094 ///
2095 /// ```text
2096 /// StartupDefaultsWriter::write → settings process mutex → settings file lock → test env lock → test state-I/O lock
2097 /// ```
2098 ///
2099 /// Two consequences worth stating, because breaking either is a deadlock:
2100 ///
2101 /// - A thread holding a transaction must never wait on
2102 /// `StartupDefaultsWriter::write`. The queued-drain paths (`flush`,
2103 /// `apply_blocking`) take `write` *first* and only then enter a transaction.
2104 /// - Under `cfg(test)` path resolution enters the process-wide env barrier from
2105 /// inside a transaction, so a background thread inside a transaction must be
2106 /// enrolled in the sealing test's env scope (see `tui::startup_defaults`) or it
2107 /// will park on a lock its own test holds.
2108 ///
2109 /// Neither lock is re-entrant. `operation` must not call back into `transact`,
2110 /// `Settings::save`, or this function.
2111 pub(crate) fn with_settings_transaction<T>(
2112 operation: impl FnOnce(&SettingsTransaction) -> Result<T>,
2113 ) -> Result<T> {
2114 let path = Settings::path()?;
2115 let _process_guard = lock_settings_transaction(settings_transaction_mutex(&path));
2116 with_settings_file_lock(&path, || {
2117 operation(&SettingsTransaction { path: path.clone() })
2118 })
2119 }
2120
2121 /// Hold an exclusive advisory lock on `<settings.toml>.lock` for `operation`.
2122 ///
2123 /// The lock file is opened (not followed) with owner-only permissions and is
2124 /// created if absent. Dropping the `fd_lock` guard — including on an unwind —
2125 /// releases it, and the OS releases it if the process dies, so a crash cannot
2126 /// wedge another Codewhale instance out of its settings.
2127 fn with_settings_file_lock<T>(path: &Path, operation: impl FnOnce() -> Result<T>) -> Result<T> {
2128 use std::fs;
2129
2130 let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) else {
2131 anyhow::bail!(
2132 "Failed to lock settings: {} has no parent directory",
2133 path.display()
2134 );
2135 };
2136 fs::create_dir_all(parent)
2137 .with_context(|| format!("Failed to create config directory {}", parent.display()))?;
2138
2139 let mut lock_name = path
2140 .file_name()
2141 .context("Failed to lock settings: settings path has no file name")?
2142 .to_os_string();
2143 lock_name.push(".lock");
2144 let lock_path = parent.join(lock_name);
2145 reject_settings_lock_symlink(&lock_path)?;
2146
2147 let mut options = fs::OpenOptions::new();
2148 options.read(true).write(true).create(true);
2149 #[cfg(unix)]
2150 {
2151 use std::os::unix::fs::OpenOptionsExt as _;
2152 options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
2153 }
2154 #[cfg(windows)]
2155 {
2156 use std::os::windows::fs::OpenOptionsExt as _;
2157 use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT;
2158 options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
2159 }
2160 let lock_file = options
2161 .open(&lock_path)
2162 .with_context(|| format!("Failed to open settings lock at {}", lock_path.display()))?;
2163 #[cfg(unix)]
2164 {
2165 use std::os::unix::fs::PermissionsExt as _;
2166 lock_file
2167 .set_permissions(fs::Permissions::from_mode(0o600))
2168 .with_context(|| {
2169 format!("Failed to secure settings lock at {}", lock_path.display())
2170 })?;
2171 }
2172 if !lock_file
2173 .metadata()
2174 .with_context(|| format!("Failed to inspect settings lock at {}", lock_path.display()))?
2175 .file_type()
2176 .is_file()
2177 {
2178 anyhow::bail!(
2179 "Refusing a non-regular settings lock at {}",
2180 lock_path.display()
2181 );
2182 }
2183
2184 let mut lock = fd_lock::RwLock::new(lock_file);
2185 let _guard = lock
2186 .write()
2187 .with_context(|| format!("Failed to acquire settings lock at {}", lock_path.display()))?;
2188 operation()
2189 }
2190
2191 /// Refuse to lock through a symlink: a planted `settings.toml.lock -> …` would
2192 /// otherwise let an attacker pick which file we create with our permissions.
2193 fn reject_settings_lock_symlink(lock_path: &Path) -> Result<()> {
2194 match std::fs::symlink_metadata(lock_path) {
2195 Ok(metadata) if metadata.file_type().is_symlink() => anyhow::bail!(
2196 "Refusing a symlinked settings lock at {}",
2197 lock_path.display()
2198 ),
2199 Ok(_) | Err(_) => Ok(()),
2200 }
2201 }
2202
2203 /// Replace `path` with `body` by writing an adjacent temporary file and
2204 /// renaming it into place.
2205 ///
2206 /// A direct `fs::write` truncates first, so any concurrent reader — another
2207 /// Codewhale process, an editor, a `cat` — can observe a half-written file and
2208 /// parse it as truncated TOML, silently losing every key past the tear. A
2209 /// same-directory temp file plus the platform's replace primitive makes the
2210 /// swap atomic for readers: they see either the whole previous file or the
2211 /// whole new one.
2212 ///
2213 /// The temp file inherits the existing file's permission bits when there is one
2214 /// (so a user who tightened `settings.toml` keeps that), and is created
2215 /// owner-only otherwise. `NamedTempFile` removes itself if anything below fails,
2216 /// so a failed save leaves no debris and never damages the previous file.
2217 fn atomically_replace_settings_file(path: &Path, body: &[u8]) -> Result<()> {
2218 use std::io::Write as _;
2219
2220 let dir = path
2221 .parent()
2222 .filter(|p| !p.as_os_str().is_empty())
2223 .unwrap_or_else(|| Path::new("."));
2224 let mut tmp = tempfile::Builder::new()
2225 .prefix(".settings-")
2226 .suffix(".tmp")
2227 .tempfile_in(dir)
2228 .with_context(|| format!("Failed to stage settings write in {}", dir.display()))?;
2229 tmp.write_all(body)
2230 .with_context(|| format!("Failed to write settings to {}", path.display()))?;
2231 tmp.flush()
2232 .with_context(|| format!("Failed to flush settings for {}", path.display()))?;
2233 tmp.as_file()
2234 .sync_all()
2235 .with_context(|| format!("Failed to sync settings for {}", path.display()))?;
2236
2237 #[cfg(unix)]
2238 {
2239 use std::os::unix::fs::PermissionsExt as _;
2240 let mode = std::fs::metadata(path)
2241 .map(|metadata| metadata.permissions().mode() & 0o777)
2242 .unwrap_or(0o600);
2243 tmp.as_file()
2244 .set_permissions(std::fs::Permissions::from_mode(mode))
2245 .with_context(|| format!("Failed to set permissions for {}", path.display()))?;
2246 }
2247
2248 #[cfg(windows)]
2249 if path.exists() {
2250 // `tempfile::persist` uses MoveFileExW on Windows. Under concurrent
2251 // reads that can expose a partially replaced destination. ReplaceFileW
2252 // is the native existing-file replacement operation and also preserves
2253 // the destination's ACLs and attributes.
2254 let mut temporary = tmp.into_temp_path();
2255 replace_existing_settings_file(path, &temporary)
2256 .with_context(|| format!("Failed to write settings to {}", path.display()))?;
2257 // ReplaceFileW consumed the temporary pathname. Do not ask TempPath to
2258 // clean up that now-nonexistent source when it drops.
2259 temporary.disable_cleanup(true);
2260 return Ok(());
2261 }
2262
2263 tmp.persist(path)
2264 .map_err(|error| error.error)
2265 .with_context(|| format!("Failed to write settings to {}", path.display()))?;
2266 Ok(())
2267 }
2268
2269 #[cfg(windows)]
2270 fn replace_existing_settings_file(path: &Path, replacement: &Path) -> std::io::Result<()> {
2271 use std::os::windows::ffi::OsStrExt as _;
2272 use windows_sys::Win32::Storage::FileSystem::{
2273 FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_TEMPORARY, ReplaceFileW, SetFileAttributesW,
2274 };
2275
2276 fn wide_path(path: &Path) -> Vec<u16> {
2277 path.as_os_str().encode_wide().chain(Some(0)).collect()
2278 }
2279
2280 let path_wide = wide_path(path);
2281 let replacement_wide = wide_path(replacement);
2282 // SAFETY: both paths are NUL-terminated and live; reserved params are null.
2283 unsafe {
2284 // NamedTempFile marks its source with the temporary caching hint.
2285 // Clear it before publication, matching tempfile's persistence path.
2286 if SetFileAttributesW(replacement_wide.as_ptr(), FILE_ATTRIBUTE_NORMAL) == 0 {
2287 return Err(std::io::Error::last_os_error());
2288 }
2289
2290 if ReplaceFileW(
2291 path_wide.as_ptr(),
2292 replacement_wide.as_ptr(),
2293 std::ptr::null(),
2294 0,
2295 std::ptr::null(),
2296 std::ptr::null(),
2297 ) == 0
2298 {
2299 let error = std::io::Error::last_os_error();
2300 // Restore the hint so TempPath retains its normal cleanup behavior
2301 // when replacement fails and the source still exists.
2302 let _ = SetFileAttributesW(replacement_wide.as_ptr(), FILE_ATTRIBUTE_TEMPORARY);
2303 return Err(error);
2304 }
2305 }
2306 Ok(())
2307 }
2308
2309 /// Per-settings-path transaction mutexes.
2310 ///
2311 /// Keyed by path rather than global because tests seal `HOME` onto their own
2312 /// temp dirs: two sealed tests write different files and have no reason to
2313 /// serialize against each other. Production has exactly one entry, so the
2314 /// registry never grows; entries are intentionally `'static` (leaked once) so a
2315 /// transaction can hold a plain `MutexGuard` without also pinning the registry
2316 /// lock it came from.
2317 fn settings_transaction_mutex(path: &Path) -> &'static std::sync::Mutex<()> {
2318 use std::collections::HashMap;
2319 use std::sync::{Mutex, OnceLock};
2320
2321 static LOCKS: OnceLock<Mutex<HashMap<PathBuf, &'static Mutex<()>>>> = OnceLock::new();
2322 let key = path.to_path_buf();
2323 let mut locks = LOCKS
2324 .get_or_init(|| Mutex::new(HashMap::new()))
2325 .lock()
2326 .unwrap_or_else(std::sync::PoisonError::into_inner);
2327 let mutex: &'static Mutex<()> = locks
2328 .entry(key)
2329 .or_insert_with(|| Box::leak(Box::new(Mutex::new(()))));
2330 drop(locks);
2331 mutex
2332 }
2333
2334 /// Acquire a transaction lock.
2335 ///
2336 /// The mutex protects ordering, not an invariant, so a panic inside one
2337 /// transaction must not wedge settings persistence for the rest of the session:
2338 /// a poisoned guard is recovered rather than propagated.
2339 #[cfg(not(test))]
2340 fn lock_settings_transaction(
2341 mutex: &'static std::sync::Mutex<()>,
2342 ) -> std::sync::MutexGuard<'static, ()> {
2343 mutex
2344 .lock()
2345 .unwrap_or_else(std::sync::PoisonError::into_inner)
2346 }
2347
2348 /// Test build of [`lock_settings_transaction`], with a watchdog.
2349 ///
2350 /// Production blocks indefinitely, which is correct — the only thing ahead of it
2351 /// is a bounded settings transaction. In a test binary an indefinite wait is
2352 /// indistinguishable from a lock-order inversion, and a hung test job reports
2353 /// nothing. This is not a synchronization device: every honest acquisition
2354 /// succeeds on the first `try_lock` or shortly after. It exists so a regression
2355 /// fails loudly instead of hanging CI.
2356 ///
2357 /// The deadline is generous on purpose. A transaction still reads and writes
2358 /// under `cfg(test)`'s state-I/O barrier, and the cross-process file lock can be
2359 /// held by a deliberately slow child process in the cross-process regressions —
2360 /// so the watchdog only has to be longer than the slowest honest transaction and
2361 /// shorter than a CI job timeout, not tight.
2362 #[cfg(test)]
2363 fn lock_settings_transaction(
2364 mutex: &'static std::sync::Mutex<()>,
2365 ) -> std::sync::MutexGuard<'static, ()> {
2366 use std::sync::TryLockError;
2367
2368 const DEADLINE: std::time::Duration = std::time::Duration::from_secs(120);
2369 let deadline = std::time::Instant::now() + DEADLINE;
2370 loop {
2371 match mutex.try_lock() {
2372 Ok(guard) => return guard,
2373 Err(TryLockError::Poisoned(poisoned)) => return poisoned.into_inner(),
2374 Err(TryLockError::WouldBlock) => {}
2375 }
2376 assert!(
2377 std::time::Instant::now() < deadline,
2378 "settings transaction lock was not released within {DEADLINE:?}. Some thread is \
2379 holding it across a load/modify/save that cannot finish — usually because it is \
2380 blocked on a lock this test already holds, or because a transaction was opened \
2381 re-entrantly. See Settings::transact."
2382 );
2383 std::thread::sleep(std::time::Duration::from_millis(1));
2384 }
2385 }
2386
2387 fn settings_path_candidates() -> (Option<PathBuf>, Option<PathBuf>, Option<PathBuf>) {
2388 settings_path_candidates_for_scope(true)
2389 }
2390
2391 fn settings_path_candidates_for_scope(
2392 include_config_override: bool,
2393 ) -> (Option<PathBuf>, Option<PathBuf>, Option<PathBuf>) {
2394 let from_environment = || {
2395 if include_config_override {
2396 settings_path_candidates_from_environment()
2397 } else {
2398 home_settings_path_candidates_from_environment()
2399 }
2400 };
2401 #[cfg(test)]
2402 {
2403 let honor_guarded_environment =
2404 crate::test_support::guarded_environment_provides_state_paths();
2405 crate::test_support::with_test_env_lock(|| {
2406 // A project-path guard cannot authorize a reader that deliberately
2407 // ignores that path. Likewise, a guarded HOME must not expose an
2408 // ambient CODEWHALE_HOME that takes precedence over it.
2409 let home_is_guarded = || {
2410 let present = |var| std::env::var_os(var).is_some_and(|value| !value.is_empty());
2411 if present("CODEWHALE_HOME") {
2412 crate::test_support::env_var_currently_guarded("CODEWHALE_HOME")
2413 } else {
2414 ["HOME", "USERPROFILE"].iter().any(|var| {
2415 crate::test_support::env_var_currently_guarded(var) && present(var)
2416 })
2417 }
2418 };
2419 if honor_guarded_environment && (include_config_override || home_is_guarded()) {
2420 from_environment()
2421 } else {
2422 (
2423 Some(crate::test_support::unsealed_test_state_root().join(SETTINGS_FILE_NAME)),
2424 None,
2425 None,
2426 )
2427 }
2428 })
2429 }
2430
2431 #[cfg(not(test))]
2432 from_environment()
2433 }
2434
2435 fn settings_path_candidates_from_environment() -> (Option<PathBuf>, Option<PathBuf>, Option<PathBuf>)
2436 {
2437 // Allow tests to override the settings directory via the same env vars
2438 // used for config. CODEWHALE_CONFIG_PATH is canonical; the legacy alias
2439 // remains a read-only fallback for existing installs.
2440 if let Some(parent) = config_override_parent() {
2441 return (Some(parent.join(SETTINGS_FILE_NAME)), None, None);
2442 }
2443
2444 home_settings_path_candidates_from_environment()
2445 }
2446
2447 fn home_settings_path_candidates_from_environment()
2448 -> (Option<PathBuf>, Option<PathBuf>, Option<PathBuf>) {
2449 let primary = codewhale_config::codewhale_home()
2450 .ok()
2451 .map(|home| home.join(SETTINGS_FILE_NAME));
2452 if codewhale_config::codewhale_home_is_explicit() {
2453 return (primary, None, None);
2454 }
2455 let legacy_home = codewhale_config::legacy_deepseek_home()
2456 .ok()
2457 .map(|home| home.join(SETTINGS_FILE_NAME));
2458 let legacy_config_dir =
2459 dirs::config_dir().map(|dir| dir.join("deepseek").join(SETTINGS_FILE_NAME));
2460
2461 (primary, legacy_home, legacy_config_dir)
2462 }
2463
2464 fn config_override_parent() -> Option<PathBuf> {
2465 fn read() -> Option<PathBuf> {
2466 for var in ["CODEWHALE_CONFIG_PATH", "DEEPSEEK_CONFIG_PATH"] {
2467 if let Ok(config_path) = std::env::var(var) {
2468 let config_path = config_path.trim();
2469 if !config_path.is_empty() {
2470 return expand_path(config_path).parent().map(Path::to_path_buf);
2471 }
2472 }
2473 }
2474 None
2475 }
2476
2477 #[cfg(test)]
2478 {
2479 crate::test_support::with_test_env_lock(read)
2480 }
2481 #[cfg(not(test))]
2482 {
2483 read()
2484 }
2485 }
2486
2487 fn migrate_settings_file_to_primary_if_needed(primary: &Path, active_read_path: &Path) {
2488 use std::io::Write as _;
2489
2490 if primary == active_read_path || primary.exists() || !active_read_path.exists() {
2491 return;
2492 }
2493
2494 let Some(parent) = primary.parent() else {
2495 return;
2496 };
2497
2498 if let Err(err) = std::fs::create_dir_all(parent) {
2499 tracing::warn!(
2500 "failed to create settings migration directory {}: {err}",
2501 parent.display()
2502 );
2503 return;
2504 }
2505
2506 let migration = (|| -> Result<()> {
2507 let body = std::fs::read(active_read_path).with_context(|| {
2508 format!(
2509 "Failed to read legacy settings from {}",
2510 active_read_path.display()
2511 )
2512 })?;
2513 let mut tmp = tempfile::Builder::new()
2514 .prefix(".settings-migration-")
2515 .suffix(".tmp")
2516 .tempfile_in(parent)
2517 .with_context(|| {
2518 format!("Failed to stage settings migration in {}", parent.display())
2519 })?;
2520 tmp.write_all(&body).with_context(|| {
2521 format!(
2522 "Failed to stage legacy settings from {}",
2523 active_read_path.display()
2524 )
2525 })?;
2526 tmp.flush()
2527 .context("Failed to flush staged settings migration")?;
2528 tmp.as_file()
2529 .sync_all()
2530 .context("Failed to sync staged settings migration")?;
2531
2532 #[cfg(unix)]
2533 {
2534 use std::os::unix::fs::PermissionsExt as _;
2535 let mode = std::fs::metadata(active_read_path)
2536 .map(|metadata| metadata.permissions().mode() & 0o777)
2537 .unwrap_or(0o600);
2538 tmp.as_file()
2539 .set_permissions(std::fs::Permissions::from_mode(mode))
2540 .context("Failed to preserve legacy settings permissions")?;
2541 }
2542
2543 match tmp.persist_noclobber(primary) {
2544 Ok(_) => Ok(()),
2545 Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
2546 Err(error) => Err(error.error).with_context(|| {
2547 format!(
2548 "Failed to install migrated settings at {}",
2549 primary.display()
2550 )
2551 }),
2552 }
2553 })();
2554
2555 if let Err(err) = migration {
2556 tracing::warn!(
2557 "failed to migrate settings from {} to {}: {err}",
2558 active_read_path.display(),
2559 primary.display()
2560 );
2561 }
2562 }
2563
2564 fn normalize_default_model(value: &str) -> Option<String> {
2565 let trimmed = value.trim();
2566 if trimmed.eq_ignore_ascii_case("auto") {
2567 Some("auto".to_string())
2568 } else {
2569 normalize_model_name(trimmed)
2570 }
2571 }
2572
2573 fn normalize_permission_posture(value: &str) -> Option<String> {
2574 match value.trim().to_ascii_lowercase().as_str() {
2575 "ask" | "suggest" | "on-request" | "untrusted" => Some("ask".to_string()),
2576 "auto" | "auto-review" | "auto_review" => Some("auto-review".to_string()),
2577 "full" | "full-access" | "full_access" | "bypass" => Some("full-access".to_string()),
2578 _ => None,
2579 }
2580 }
2581
2582 /// Normalize filesystem sandbox mode. Distinct from permission posture.
2583 fn normalize_sandbox_mode(value: &str) -> Option<String> {
2584 match value.trim().to_ascii_lowercase().as_str() {
2585 "read-only" | "readonly" | "read_only" | "ro" => Some("read-only".to_string()),
2586 "workspace-write" | "workspace_write" | "workspace" | "workspace-only" => {
2587 Some("workspace-write".to_string())
2588 }
2589 "danger-full-access" | "danger_full_access" | "full-fs" | "full_filesystem"
2590 | "filesystem-full" => Some("danger-full-access".to_string()),
2591 "external-sandbox" | "external_sandbox" | "opensandbox" | "external" => {
2592 Some("external-sandbox".to_string())
2593 }
2594 _ => None,
2595 }
2596 }
2597
2598 fn normalize_reasoning_effort_setting(value: &str) -> Result<Option<String>> {
2599 let trimmed = value.trim();
2600 if trimmed.is_empty()
2601 || matches!(
2602 trimmed.to_ascii_lowercase().as_str(),
2603 "default" | "(default)" | "config" | "configured" | "unset"
2604 )
2605 {
2606 return Ok(None);
2607 }
2608
2609 ReasoningEffort::parse_strict(trimmed)
2610 .map(|effort| Some(effort.as_setting().to_string()))
2611 .map_err(|err| anyhow::anyhow!("Failed to update setting: {err}"))
2612 }
2613
2614 /// Parse a boolean value from various formats
2615 pub(crate) fn parse_bool(value: &str) -> Result<bool> {
2616 match value.to_lowercase().as_str() {
2617 "on" | "true" | "yes" | "1" | "enabled" => Ok(true),
2618 "off" | "false" | "no" | "0" | "disabled" => Ok(false),
2619 _ => {
2620 anyhow::bail!("Failed to parse boolean '{value}': expected on/off, true/false, yes/no.")
2621 }
2622 }
2623 }
2624
2625 fn default_thinking_preview_lines() -> usize {
2626 2
2627 }
2628
2629 fn default_true() -> bool {
2630 true
2631 }
2632
2633 fn parse_usize_setting(key: &str, value: &str) -> Result<usize> {
2634 value.trim().parse::<usize>().map_err(|_| {
2635 anyhow::anyhow!(
2636 "Failed to update setting: invalid {key} '{value}'. Expected 0 or a positive integer."
2637 )
2638 })
2639 }
2640
2641 fn parse_u16_range(key: &str, value: &str, min: u16, max: u16) -> Result<u16> {
2642 let parsed = value
2643 .trim()
2644 .parse::<u16>()
2645 .map_err(|_| anyhow::anyhow!("Invalid {key} '{value}': expected {min}-{max}"))?;
2646 if !(min..=max).contains(&parsed) {
2647 anyhow::bail!("Invalid {key} '{value}': expected {min}-{max}");
2648 }
2649 Ok(parsed)
2650 }
2651
2652 fn parse_percent_setting(key: &str, value: &str) -> Result<f64> {
2653 let trimmed = value.trim().trim_end_matches('%').trim();
2654 let percent = trimmed.parse::<f64>().map_err(|_| {
2655 anyhow::anyhow!(
2656 "Failed to update setting: invalid {key} '{value}'. Expected a number from 10 to 100."
2657 )
2658 })?;
2659 if !(10.0..=100.0).contains(&percent) {
2660 anyhow::bail!(
2661 "Failed to update setting: invalid {key} '{value}'. Expected a number from 10 to 100."
2662 );
2663 }
2664 Ok(percent)
2665 }
2666
2667 fn normalize_mention_menu_behavior(value: &str) -> Result<String> {
2668 match value.trim().to_ascii_lowercase().as_str() {
2669 "fuzzy" | "default" => Ok("fuzzy".to_string()),
2670 "browser" | "browse" | "file-browser" | "file_browser" => Ok("browser".to_string()),
2671 _ => {
2672 anyhow::bail!(
2673 "Failed to update setting: invalid mention_menu_behavior '{value}'. Expected: fuzzy, browser."
2674 )
2675 }
2676 }
2677 }
2678
2679 fn normalize_mode(value: &str) -> &str {
2680 match value.trim().to_ascii_lowercase().as_str() {
2681 "edit" => "agent",
2682 "normal" => "agent",
2683 "agent" | "act" | "work" => "agent",
2684 "plan" => "plan",
2685 // Operate is a first-class startup mode (Hunter 2026-07-24).
2686 "operate" | "operation" | "ops" => "operate",
2687 // yolo was mode+permission; keep mode as Act and migrate posture on load.
2688 "yolo" => "agent",
2689 _ => value,
2690 }
2691 }
2692
2693 fn normalize_composer_density(value: &str) -> &str {
2694 match value.trim().to_ascii_lowercase().as_str() {
2695 "compact" | "tight" => "compact",
2696 "comfortable" | "default" | "normal" => "comfortable",
2697 "spacious" | "loose" => "spacious",
2698 _ => value,
2699 }
2700 }
2701
2702 fn normalize_transcript_spacing(value: &str) -> &str {
2703 match value.trim().to_ascii_lowercase().as_str() {
2704 "compact" | "tight" => "compact",
2705 "comfortable" | "default" | "normal" => "comfortable",
2706 "spacious" | "loose" => "spacious",
2707 _ => value,
2708 }
2709 }
2710
2711 fn normalize_tool_collapse_mode(value: &str) -> &str {
2712 match value.trim().to_ascii_lowercase().as_str() {
2713 "compact" | "collapsed" | "collapse" | "default" | "on" | "true" => "compact",
2714 "expanded" | "expand" | "off" | "none" | "false" => "expanded",
2715 "calm" | "calm_mode" | "calm-mode" | "calm_only" | "calm-only" => "calm",
2716 _ => value,
2717 }
2718 }
2719
2720 /// Normalize the `status_indicator` header chip setting. Accepts the
2721 /// canonical names plus common aliases ("none"/"hidden" → "off",
2722 /// "dot" → "dots"). Unknown values fall through unchanged so the parser
2723 /// in `update_setting` can surface a clear error.
2724 fn normalize_status_indicator(value: &str) -> &str {
2725 match value.trim().to_ascii_lowercase().as_str() {
2726 "cw" | "mark" | "text" => "cw",
2727 // The whale emoji header chip is retired (2026-07-23): persisted
2728 // opt-ins migrate to the typographic mark on load.
2729 "whale" | "🐳" | "🐋" => "cw",
2730 "dots" | "dot" => "dots",
2731 "off" | "none" | "hidden" | "false" => "off",
2732 _ => value,
2733 }
2734 }
2735
2736 /// Normalize the `synchronized_output` setting. Accepts the canonical
2737 /// `"auto"` / `"on"` / `"off"` plus the usual truthy/falsey spellings.
2738 /// Unknown values fall through unchanged so the parser in `set` can
2739 /// surface a clear error.
2740 fn normalize_synchronized_output(value: &str) -> &str {
2741 match value.trim().to_ascii_lowercase().as_str() {
2742 "auto" | "default" => "auto",
2743 "on" | "true" | "yes" | "1" | "enabled" => "on",
2744 "off" | "false" | "no" | "0" | "disabled" => "off",
2745 _ => value,
2746 }
2747 }
2748
2749 fn normalize_settings_theme(value: &str) -> String {
2750 // Unknown persisted selectors fall back to the same fresh-install default.
2751 // Valid saved choices, including Shoreline, remain unchanged.
2752 normalize_theme_setting(value).unwrap_or_else(|_| DEFAULT_TUI_THEME.to_string())
2753 }
2754
2755 /// Returns `true` when the active terminal is Ptyxis (the new default
2756 /// terminal on Ubuntu 26.04). Used by [`Settings::apply_env_overrides`]
2757 /// to flip `synchronized_output` from `auto` to `off` so DEC mode 2026
2758 /// flicker on Ptyxis 50.x + VTE 0.84.x stops at the source.
2759 ///
2760 /// We deliberately keep this narrow:
2761 ///
2762 /// - `TERM_PROGRAM` matches `ptyxis` case-insensitively (the value
2763 /// Ptyxis sets when it forwards a process-launch context).
2764 /// - `PTYXIS_VERSION` is set to any non-empty value (the binary's
2765 /// own version probe, present whether or not `TERM_PROGRAM` made it
2766 /// into the child environment).
2767 ///
2768 /// Either signal is sufficient. We do *not* trigger on `VTE_VERSION`
2769 /// alone because gnome-terminal 3.58 ships with the same VTE 0.84.x
2770 /// and renders cleanly — broadening the heuristic would regress every
2771 /// gnome-terminal user.
2772 pub fn detected_ptyxis_terminal() -> bool {
2773 if let Ok(program) = std::env::var("TERM_PROGRAM")
2774 && program.trim().to_ascii_lowercase().contains("ptyxis")
2775 {
2776 return true;
2777 }
2778 matches!(std::env::var("PTYXIS_VERSION"), Ok(v) if !v.trim().is_empty())
2779 }
2780
2781 /// Returns `true` for the unmarked Windows console-host path used by plain
2782 /// PowerShell / cmd.exe. Modern Windows terminals set at least one marker that
2783 /// lets us keep the richer rendering path.
2784 pub fn detected_legacy_windows_console_host() -> bool {
2785 cfg!(windows)
2786 && legacy_windows_console_host_env([
2787 std::env::var_os("WT_SESSION").as_deref(),
2788 std::env::var_os("ConEmuPID").as_deref(),
2789 std::env::var_os("TERM_PROGRAM").as_deref(),
2790 std::env::var_os("WEZTERM_EXECUTABLE").as_deref(),
2791 std::env::var_os("WEZTERM_PANE").as_deref(),
2792 std::env::var_os("ALACRITTY_WINDOW_ID").as_deref(),
2793 std::env::var_os("ANSICON").as_deref(),
2794 std::env::var_os("TERM").as_deref(),
2795 ])
2796 }
2797
2798 fn legacy_windows_console_host_env(markers: [Option<&std::ffi::OsStr>; 8]) -> bool {
2799 fn has_value(value: Option<&std::ffi::OsStr>) -> bool {
2800 value.is_some_and(|v| !v.is_empty())
2801 }
2802
2803 markers.into_iter().all(|value| !has_value(value))
2804 }
2805
2806 fn normalize_optional_background_color(value: Option<&str>) -> Option<String> {
2807 value.and_then(|raw| normalize_background_color_setting(raw).ok().flatten())
2808 }
2809
2810 fn normalize_background_color_setting(value: &str) -> Result<Option<String>> {
2811 let trimmed = value.trim();
2812 if trimmed.is_empty()
2813 || matches!(
2814 trimmed.to_ascii_lowercase().as_str(),
2815 "default" | "none" | "reset" | "off"
2816 )
2817 {
2818 return Ok(None);
2819 }
2820
2821 normalize_hex_rgb_color(trimmed).map(Some).ok_or_else(|| {
2822 anyhow::anyhow!(
2823 "Failed to update setting: invalid background_color '{value}'. Expected #RRGGBB, RRGGBB, or default."
2824 )
2825 })
2826 }
2827
2828 fn normalize_sidebar_focus(value: &str) -> &str {
2829 match value.trim().to_ascii_lowercase().as_str() {
2830 "pinned" | "visible" | "show" | "on" | "work" | "plan" | "todos" => "pinned",
2831 "tasks" | "activity" | "live" | "running" => "tasks",
2832 "agents" | "subagents" | "sub-agents" => "agents",
2833 "context" => "context",
2834 "sessions" | "sessions_rail" | "session_history" => "sessions",
2835 "hidden" | "hide" | "closed" | "off" | "none" => "hidden",
2836 _ => "auto",
2837 }
2838 }
2839
2840 fn is_false(value: &bool) -> bool {
2841 !*value
2842 }
2843
2844 /// Resolve an environment variable as a boolean. Recognises the
2845 /// common truthy spellings (`1`, `true`, `yes`, `on`) case-
2846 /// insensitively. Used by [`Settings::apply_env_overrides`] for
2847 /// platform a11y signals like `NO_ANIMATIONS`.
2848 fn env_truthy(name: &str) -> bool {
2849 match std::env::var(name) {
2850 Ok(v) => matches!(
2851 v.trim().to_ascii_lowercase().as_str(),
2852 "1" | "true" | "yes" | "on"
2853 ),
2854 Err(_) => false,
2855 }
2856 }
2857
2858 #[cfg(test)]
2859 mod tests {
2860 use super::*;
2861
2862 /// The override detector names the same winner `apply_env_overrides`
2863 /// applies: `NO_ANIMATIONS` is first in precedence and is environment,
2864 /// not terminal, authority.
2865 #[test]
2866 fn low_motion_override_detector_agrees_with_env_overlay() {
2867 let _lock = crate::test_support::lock_test_env();
2868 let _no_animations = crate::test_support::EnvVarGuard::set("NO_ANIMATIONS", "1");
2869
2870 let detected = detect_low_motion_override();
2871 assert_eq!(detected, Some(MotionOverride::NoAnimationsEnv));
2872 assert!(detected.is_some_and(MotionOverride::is_environment));
2873 assert_eq!(detected.map(MotionOverride::label), Some("NO_ANIMATIONS"));
2874
2875 let mut settings = Settings::default();
2876 assert!(!settings.low_motion);
2877 settings.apply_env_overrides();
2878 assert!(settings.low_motion, "the overlay forces low motion on");
2879 assert!(!settings.fancy_animations);
2880 }
2881
2882 // -----------------------------------------------------------------------
2883 // Cross-process settings integrity
2884 // -----------------------------------------------------------------------
2885 //
2886 // The in-process mutex says nothing about a *second* Codewhale process on
2887 // the same home directory — a second TUI, `codewhale exec`, the runtime HTTP
2888 // surface in another instance. Two of those doing load/modify/save at once
2889 // is the same last-save-wins bug the in-process lock was added to prevent,
2890 // and no amount of thread-based testing can observe it: threads share the
2891 // mutex that makes the bug impossible. These regressions therefore drive a
2892 // real child process.
2893 //
2894 // The child is this same test binary, re-invoked with `--ignored --exact`
2895 // on the helper below. It inherits the sealed `HOME`/`CODEWHALE_HOME`
2896 // through its environment, so both processes resolve the same
2897 // `settings.toml`.
2898
2899 /// Selects which child behavior [`settings_cross_process_child_helper`] runs.
2900 const CHILD_ROLE_ENV: &str = "CODEWHALE_TEST_SETTINGS_CHILD_ROLE";
2901 /// Path of the parent↔child handshake file. Its meaning is per-role: the
2902 /// slow writer *creates* it once its transaction is open; the reader *waits*
2903 /// for it as a stop signal.
2904 const CHILD_SIGNAL_ENV: &str = "CODEWHALE_TEST_SETTINGS_CHILD_SIGNAL";
2905 /// Where the child writes what it observed, for the parent to assert on.
2906 const CHILD_RESULT_ENV: &str = "CODEWHALE_TEST_SETTINGS_CHILD_RESULT";
2907
2908 /// The other process in the cross-process regressions.
2909 ///
2910 /// Ignored so a normal `cargo test` never runs it directly; the parent tests
2911 /// invoke it explicitly with `--ignored --exact`. With no role set it is a
2912 /// no-op, so an accidental `--ignored` sweep stays green.
2913 #[test]
2914 #[ignore = "spawned as a child process by the cross-process settings regressions"]
2915 fn settings_cross_process_child_helper() {
2916 use std::time::{Duration, Instant};
2917
2918 let Ok(role) = std::env::var(CHILD_ROLE_ENV) else {
2919 return;
2920 };
2921 // Under `cfg(test)` the settings path only honors the real environment
2922 // for a thread that holds this lock; without it the child would resolve
2923 // the isolated per-process test root and never touch the parent's file.
2924 // The child is a fresh process, so the acquisition is uncontended.
2925 let _env_lock = crate::test_support::lock_test_env();
2926 let inherited_home = std::env::var_os("CODEWHALE_HOME")
2927 .expect("settings child needs an inherited Codewhale home");
2928 let _state_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", inherited_home);
2929 let signal = PathBuf::from(
2930 std::env::var(CHILD_SIGNAL_ENV).expect("child helper needs a signal path"),
2931 );
2932
2933 match role.as_str() {
2934 // Hold the settings critical section open across a visible delay, so
2935 // the parent's transaction is guaranteed to arrive while this one is
2936 // mid-flight.
2937 "slow-writer" => {
2938 with_settings_transaction(|transaction| {
2939 let mut settings = transaction.load()?;
2940 settings.default_mode = "operate".to_string();
2941 // Announce *after* the read: from here on, any parent write
2942 // that is not excluded by the lock will be lost by the save
2943 // below.
2944 std::fs::write(&signal, b"loaded").expect("write the handshake file");
2945 std::thread::sleep(Duration::from_millis(1_500));
2946 transaction.save(&settings)
2947 })
2948 .expect("the child transaction must commit");
2949 }
2950 // Read the raw file as fast as possible while the parent rewrites
2951 // it, and report how many reads were torn.
2952 "reader" => {
2953 let result = PathBuf::from(
2954 std::env::var(CHILD_RESULT_ENV).expect("reader needs a result path"),
2955 );
2956 let path = Settings::path().expect("resolve the shared settings path");
2957 let ready = result.with_extension("ready");
2958 let deadline = Instant::now() + Duration::from_secs(60);
2959 let (mut reads, mut torn) = (0_u64, 0_u64);
2960
2961 // A ready marker must mean that the reader has actually run.
2962 // On Windows the child can otherwise create the marker, lose
2963 // its time slice, and perform no reads before the parent
2964 // completes every write and signals it to stop.
2965 loop {
2966 assert!(
2967 Instant::now() < deadline,
2968 "reader did not observe the seeded settings file"
2969 );
2970 match std::fs::read_to_string(&path) {
2971 Ok(raw)
2972 if !raw.is_empty() && toml::from_str::<toml::Value>(&raw).is_ok() =>
2973 {
2974 reads += 1;
2975 break;
2976 }
2977 Ok(_) | Err(_) => std::thread::yield_now(),
2978 }
2979 }
2980 std::fs::write(&ready, b"ready").expect("announce that the reader is ready");
2981
2982 while !path_exists_for_test(&signal) && Instant::now() < deadline {
2983 let Ok(raw) = std::fs::read_to_string(&path) else {
2984 // The file legitimately does not exist yet.
2985 continue;
2986 };
2987 reads += 1;
2988 // Both failure shapes a truncate-then-write produces: the
2989 // momentarily empty file, and a prefix that stops mid-value.
2990 if raw.is_empty() || toml::from_str::<toml::Value>(&raw).is_err() {
2991 torn += 1;
2992 }
2993 }
2994 std::fs::write(&result, format!("{reads} {torn}")).expect("write the result file");
2995 }
2996 other => panic!("unknown child role {other}"),
2997 }
2998 }
2999
3000 fn path_exists_for_test(path: &Path) -> bool {
3001 std::fs::metadata(path).is_ok()
3002 }
3003
3004 /// Spawn this test binary as a child running the helper above in `role`.
3005 fn spawn_settings_child(
3006 role: &str,
3007 home: &Path,
3008 signal: &Path,
3009 result: Option<&Path>,
3010 ) -> std::process::Child {
3011 let mut command = std::process::Command::new(
3012 std::env::current_exe().expect("the test binary path is the child program"),
3013 );
3014 command
3015 .arg("settings::tests::settings_cross_process_child_helper")
3016 .args(["--exact", "--ignored", "--test-threads", "1"])
3017 .env(CHILD_ROLE_ENV, role)
3018 .env(CHILD_SIGNAL_ENV, signal)
3019 .env("HOME", home)
3020 .env("USERPROFILE", home)
3021 .env("CODEWHALE_HOME", home.join(".codewhale"))
3022 .env_remove("DEEPSEEK_CONFIG_PATH")
3023 .env_remove("CODEWHALE_CONFIG_PATH")
3024 .stdout(std::process::Stdio::null())
3025 .stderr(std::process::Stdio::null());
3026 if let Some(result) = result {
3027 command.env(CHILD_RESULT_ENV, result);
3028 }
3029 command.spawn().expect("spawn the settings child process")
3030 }
3031
3032 fn seal_settings_home_for_test(home: &Path) -> Vec<crate::test_support::EnvVarGuard> {
3033 use crate::test_support::EnvVarGuard;
3034 vec![
3035 EnvVarGuard::set("HOME", home),
3036 EnvVarGuard::set("USERPROFILE", home),
3037 EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale")),
3038 EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH"),
3039 EnvVarGuard::remove("CODEWHALE_CONFIG_PATH"),
3040 ]
3041 }
3042
3043 fn wait_for_file(path: &Path, what: &str) {
3044 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
3045 while !path_exists_for_test(path) {
3046 assert!(
3047 std::time::Instant::now() < deadline,
3048 "timed out waiting for {what} at {}",
3049 path.display()
3050 );
3051 std::thread::sleep(std::time::Duration::from_millis(5));
3052 }
3053 }
3054
3055 /// Two processes mutating **disjoint** fields must both survive.
3056 ///
3057 /// The child opens a transaction, reads the pre-image, announces itself, and
3058 /// only then saves `default_mode`. The parent's `max_history` write arrives
3059 /// squarely inside that window. Without the cross-process lock the parent
3060 /// loads the same pre-image, saves, and is then overwritten wholesale by the
3061 /// child's later save — `max_history` silently reverts. With the lock the
3062 /// parent waits, re-reads the child's committed value, and both fields land.
3063 #[test]
3064 fn two_processes_mutating_disjoint_fields_do_not_last_save_wins() {
3065 let _lock = crate::test_support::lock_test_env();
3066 let tmp = tempfile::TempDir::new().expect("tempdir");
3067 let _env = seal_settings_home_for_test(tmp.path());
3068
3069 // A real pre-image, so "whichever saves last wins" has something to
3070 // revert rather than a fresh file.
3071 Settings::transact(|settings| settings.set("max_history", "100"))
3072 .expect("seed the settings file");
3073 let signal = tmp.path().join("child-transaction-open");
3074
3075 let mut child = spawn_settings_child("slow-writer", tmp.path(), &signal, None);
3076 wait_for_file(&signal, "the child's open transaction");
3077
3078 // The child is mid-transaction right now. This must block, not race.
3079 Settings::transact(|settings| settings.set("max_history", "321"))
3080 .expect("the parent write must land once the child releases the lock");
3081
3082 let status = child.wait().expect("await the child process");
3083 assert!(status.success(), "the child transaction must succeed");
3084
3085 let settled = Settings::load_persisted().expect("reload the shared settings");
3086 assert_eq!(
3087 settled.default_mode, "operate",
3088 "the child's field must survive the parent's whole-file save"
3089 );
3090 assert_eq!(
3091 settled.max_input_history, 321,
3092 "the parent's field must survive the child's whole-file save"
3093 );
3094 }
3095
3096 /// A concurrent reader must never observe a half-written `settings.toml`.
3097 ///
3098 /// `fs::write` truncates before it writes, so any other process reading at
3099 /// the wrong moment sees an empty file or a prefix that stops mid-value —
3100 /// and parses it as a settings file that is simply missing everything past
3101 /// the tear. Writing to an adjacent temp file and renaming makes the swap
3102 /// atomic: a reader sees either the whole old file or the whole new one.
3103 #[test]
3104 fn concurrent_readers_never_observe_a_truncated_settings_file() {
3105 let _lock = crate::test_support::lock_test_env();
3106 let tmp = tempfile::TempDir::new().expect("tempdir");
3107 let _env = seal_settings_home_for_test(tmp.path());
3108
3109 // Make the file big enough that a non-atomic write has a real window.
3110 // A short file can be written in one syscall and hide the bug.
3111 Settings::transact(|settings| {
3112 settings.pinned_models = (0..400)
3113 .map(|index| PinnedModel {
3114 provider: "deepseek".to_string(),
3115 model: format!("pinned-model-{index:04}"),
3116 label: Some(format!("Pinned model {index:04}")),
3117 })
3118 .collect();
3119 Ok(())
3120 })
3121 .expect("seed a large settings file");
3122
3123 let stop = tmp.path().join("reader-stop");
3124 let result = tmp.path().join("reader-result");
3125 let ready = result.with_extension("ready");
3126 let mut child = spawn_settings_child("reader", tmp.path(), &stop, Some(&result));
3127 wait_for_file(&ready, "the settings reader to become ready");
3128
3129 for index in 0..150 {
3130 Settings::transact(|settings| settings.set("max_history", &(100 + index).to_string()))
3131 .expect("the parent write must land");
3132 }
3133
3134 std::fs::write(&stop, b"stop").expect("signal the reader to stop");
3135 let status = child.wait().expect("await the reader process");
3136 assert!(status.success(), "the reader must exit cleanly");
3137
3138 let observed = std::fs::read_to_string(&result).expect("read the reader's report");
3139 let mut parts = observed.split_whitespace();
3140 let reads: u64 = parts.next().and_then(|v| v.parse().ok()).unwrap_or(0);
3141 let torn: u64 = parts.next().and_then(|v| v.parse().ok()).unwrap_or(0);
3142 assert!(
3143 reads > 0,
3144 "the reader observed nothing, so it proves nothing (report: {observed:?})"
3145 );
3146 assert_eq!(
3147 torn, 0,
3148 "{torn} of {reads} concurrent reads saw a truncated or unparseable settings file"
3149 );
3150 }
3151
3152 #[test]
3153 fn focus_texture_defaults_off_and_validates() {
3154 let mut settings = Settings::default();
3155 assert_eq!(settings.focus_texture, "off");
3156
3157 settings.set("focus_texture", "scrim").unwrap();
3158 assert_eq!(settings.focus_texture, "scrim");
3159 settings.set("texture", "grain").unwrap();
3160 assert_eq!(settings.focus_texture, "grain");
3161 settings.set("focus_texture", " OFF ").unwrap();
3162 assert_eq!(settings.focus_texture, "off");
3163
3164 let err = settings.set("focus_texture", "static").unwrap_err();
3165 assert!(err.to_string().contains("off, scrim, or grain"));
3166 }
3167
3168 #[test]
3169 fn retired_ocean_treatment_folds_into_the_underwater_theme() {
3170 let tmp = tempfile::tempdir().expect("tempdir");
3171 let path = tmp.path().join("settings.toml");
3172 std::fs::write(&path, "theme = \"light\"\nocean_treatment = \"deepsea\"\n")
3173 .expect("legacy settings");
3174
3175 let settings = Settings::load_persisted_from_candidates(Some(path.clone()), None, None)
3176 .expect("legacy setting must remain readable");
3177 assert_eq!(
3178 settings.theme, "underwater",
3179 "the persisted painted field is the user-visible fact; it becomes the theme"
3180 );
3181
3182 settings
3183 .save_to_path(&path)
3184 .expect("save normalized settings");
3185 let saved = std::fs::read_to_string(&path).expect("read normalized settings");
3186 assert!(
3187 !saved.contains("ocean_treatment"),
3188 "the retired key must not be written back: {saved}"
3189 );
3190 assert!(saved.contains("theme = \"underwater\""), "{saved}");
3191 }
3192
3193 #[test]
3194 fn flat_ocean_treatment_leaves_the_theme_alone_and_is_dropped() {
3195 let tmp = tempfile::tempdir().expect("tempdir");
3196 let path = tmp.path().join("settings.toml");
3197 std::fs::write(&path, "theme = \"light\"\nocean_treatment = \"flat\"\n")
3198 .expect("legacy settings");
3199
3200 let settings = Settings::load_persisted_from_candidates(Some(path.clone()), None, None)
3201 .expect("legacy setting must remain readable");
3202 assert_eq!(
3203 settings.theme, "light",
3204 "flat never opted into a painted field"
3205 );
3206
3207 settings
3208 .save_to_path(&path)
3209 .expect("save normalized settings");
3210 let saved = std::fs::read_to_string(&path).expect("read normalized settings");
3211 assert!(!saved.contains("ocean_treatment"), "{saved}");
3212 assert!(saved.contains("theme = \"light\""), "{saved}");
3213 }
3214
3215 #[test]
3216 fn work_surface_placement_persists_all_placements_with_bottom_default() {
3217 let mut settings = Settings::default();
3218 // Round 3 (2026-09-01): the bar's information lives under the
3219 // composer, so Bottom is the default.
3220 assert_eq!(settings.work_surface_placement, "bottom");
3221
3222 for placement in ["bottom", "top", "left", "right", "off"] {
3223 settings
3224 .set("work_surface_placement", placement)
3225 .expect("valid placement");
3226 assert_eq!(settings.work_surface_placement, placement);
3227 let body = toml::to_string(&settings).expect("serialize settings");
3228 let restored: Settings = toml::from_str(&body).expect("restore settings");
3229 assert_eq!(restored.work_surface_placement, placement);
3230 }
3231
3232 let err = settings
3233 .set("work_surface_placement", "diagonal")
3234 .expect_err("nonsense placement");
3235 assert!(err.to_string().contains("top, bottom, left, right, or off"));
3236 assert_eq!(settings.work_surface_placement, "off");
3237 }
3238
3239 #[test]
3240 fn rail_panel_persists_every_dock_panel_and_folds_pinned_into_tasks() {
3241 let mut settings = Settings::default();
3242 assert_eq!(settings.rail_panel, "tasks");
3243
3244 // Every panel the dock cycles through must survive `set` and a
3245 // settings.toml round trip — the dock persists all eight.
3246 for panel in [
3247 "tasks",
3248 "agents",
3249 "background",
3250 "files",
3251 "notepad",
3252 "context",
3253 "git",
3254 "price",
3255 ] {
3256 settings.set("rail_panel", panel).expect("valid panel");
3257 assert_eq!(settings.rail_panel, panel);
3258 let body = toml::to_string(&settings).expect("serialize settings");
3259 let restored: Settings = toml::from_str(&body).expect("restore settings");
3260 assert_eq!(restored.rail_panel, panel);
3261 }
3262
3263 // `pinned` stays accepted as a setting word but persists as the
3264 // canonical tasks view, matching the load-time migration.
3265 settings.set("rail_panel", "agents").expect("reset panel");
3266 settings.set("rail_panel", "pinned").expect("pinned alias");
3267 assert_eq!(settings.rail_panel, "tasks");
3268
3269 let err = settings
3270 .set("rail_panel", "auto")
3271 .expect_err("auto-collapse was dropped with the legacy sidebar");
3272 assert!(
3273 err.to_string()
3274 .contains("tasks, agents, background, files, notepad, context, git, or price")
3275 );
3276 assert_eq!(settings.rail_panel, "tasks");
3277 }
3278
3279 #[test]
3280 fn work_surface_drag_sizes_round_trip_with_bounded_values() {
3281 let mut settings = Settings::default();
3282 settings.set("work_surface_top_height", "9").unwrap();
3283 settings.set("work_surface_side_width", "54").unwrap();
3284 let body = toml::to_string(&settings).expect("serialize settings");
3285 let restored: Settings = toml::from_str(&body).expect("restore settings");
3286 assert_eq!(restored.work_surface_top_height, 9);
3287 assert_eq!(restored.work_surface_side_width, 54);
3288 assert!(settings.set("work_surface_top_height", "17").is_err());
3289 assert!(settings.set("work_surface_top_height", "4").is_err());
3290 assert!(settings.set("work_surface_side_width", "25").is_err());
3291 }
3292
3293 #[test]
3294 fn settings_load_keeps_top_placement_chosen_after_the_bottom_migration() {
3295 let _g = config_path_test_guard();
3296 let tmp = tempfile::tempdir().expect("tempdir");
3297 let settings_path = tmp.path().join("settings.toml");
3298 std::fs::write(
3299 &settings_path,
3300 "work_surface_placement = \"top\"\nwork_surface_bottom_migrated = true\n",
3301 )
3302 .expect("settings");
3303 let _config_override =
3304 EnvVarRestore::set("DEEPSEEK_CONFIG_PATH", tmp.path().join("config.toml"));
3305
3306 let loaded = Settings::load().expect("load settings");
3307 assert_eq!(loaded.work_surface_placement, "top");
3308 }
3309
3310 #[test]
3311 fn settings_load_migrates_unreadable_top_work_surface_height() {
3312 let _g = config_path_test_guard();
3313 let tmp = tempfile::tempdir().expect("tempdir");
3314 let settings_path = tmp.path().join("settings.toml");
3315 let legacy = "work_surface_placement = \"top\"\nwork_surface_top_height = 2\nrail_panel = \"pinned\"\n";
3316 std::fs::write(&settings_path, legacy).expect("settings");
3317 let _config_override =
3318 EnvVarRestore::set("DEEPSEEK_CONFIG_PATH", tmp.path().join("config.toml"));
3319
3320 let loaded = Settings::load().expect("load settings");
3321
3322 assert_eq!(loaded.work_surface_top_height, WORK_SURFACE_TOP_HEIGHT_MIN);
3323 // Round 3: a persisted `top` from before the default moved is the
3324 // old default, migrated once to `bottom` (0.9.12 defect #9).
3325 assert_eq!(loaded.work_surface_placement, "bottom");
3326 assert!(loaded.work_surface_bottom_migrated);
3327 // `pinned` folded into the tasks view (2026-09-02 dock views).
3328 assert_eq!(loaded.rail_panel, "tasks");
3329 assert_eq!(
3330 std::fs::read_to_string(settings_path).expect("read unchanged settings"),
3331 legacy,
3332 "normalizing a legacy height at read time must not rewrite the user's file"
3333 );
3334 }
3335
3336 #[test]
3337 fn inline_diffs_default_full_and_persist_exactly_one_mode() {
3338 let mut settings = Settings::default();
3339 assert_eq!(settings.inline_diffs, "full");
3340 assert_eq!(
3341 InlineDiffMode::parse(&settings.inline_diffs),
3342 InlineDiffMode::Full
3343 );
3344
3345 for mode in ["summary", "off", "full"] {
3346 settings.set("inline_diffs", mode).expect("valid mode");
3347 assert_eq!(settings.inline_diffs, mode);
3348 let body = toml::to_string(&settings).expect("serialize settings");
3349 let restored: Settings = toml::from_str(&body).expect("restore settings");
3350 assert_eq!(restored.inline_diffs, mode);
3351 }
3352
3353 let error = settings
3354 .set("inline_diffs", "compact")
3355 .expect_err("unknown mode must not be guessed");
3356 assert!(error.to_string().contains("full, summary, or off"));
3357 assert_eq!(settings.inline_diffs, "full");
3358 }
3359
3360 #[test]
3361 fn thinking_highlight_is_independently_configurable_and_persisted() {
3362 let mut settings = Settings::default();
3363 assert!(settings.thinking_highlight);
3364
3365 settings
3366 .set("thinking_highlight", "false")
3367 .expect("valid thinking highlight setting");
3368 assert!(!settings.thinking_highlight);
3369
3370 let restored: Settings =
3371 toml::from_str(&toml::to_string(&settings).expect("serialize settings"))
3372 .expect("restore settings");
3373 assert!(!restored.thinking_highlight);
3374 }
3375
3376 #[test]
3377 fn thinking_default_expanded_is_opt_in_and_persisted() {
3378 let mut settings = Settings::default();
3379 assert!(!settings.thinking_default_expanded);
3380
3381 settings
3382 .set("thinking_default_expanded", "true")
3383 .expect("valid thinking expansion setting");
3384 assert!(settings.thinking_default_expanded);
3385
3386 let restored: Settings =
3387 toml::from_str(&toml::to_string(&settings).expect("serialize settings"))
3388 .expect("restore settings");
3389 assert!(restored.thinking_default_expanded);
3390 }
3391
3392 #[test]
3393 fn density_knobs_default_compact_and_persist() {
3394 let mut settings = Settings::default();
3395 assert_eq!(settings.thinking_preview_lines, 2);
3396 assert!(!settings.help_expand_groups);
3397 assert!(settings.pin_last_prompt);
3398
3399 settings.set("thinking_preview_lines", "10").unwrap();
3400 settings.set("help_expand_groups", "true").unwrap();
3401 settings.set("pin_last_prompt", "false").unwrap();
3402 assert_eq!(settings.thinking_preview_lines, 10);
3403 assert!(settings.help_expand_groups);
3404 assert!(!settings.pin_last_prompt);
3405
3406 let restored: Settings =
3407 toml::from_str(&toml::to_string(&settings).expect("serialize settings"))
3408 .expect("restore settings");
3409 assert_eq!(restored.thinking_preview_lines, 10);
3410 assert!(restored.help_expand_groups);
3411 assert!(!restored.pin_last_prompt);
3412 }
3413
3414 /// Explicit animated baseline for env-force tests (#4095 flipped defaults to calm).
3415 fn animated_settings() -> Settings {
3416 Settings {
3417 calm_mode: false,
3418 low_motion: false,
3419 load_error: None,
3420 fancy_animations: true,
3421 show_tool_details: true,
3422 transcript_spacing: "comfortable".to_string(),
3423 ..Settings::default()
3424 }
3425 }
3426
3427 #[test]
3428 fn apply_preset_calm_sets_bundle_and_preserves_evidence() {
3429 let mut settings = Settings::default();
3430 // Density is calm by default; motion is an independent axis.
3431 assert!(settings.calm_mode);
3432 assert!(!settings.show_thinking);
3433
3434 let changed = settings.apply_preset("CALM").expect("calm preset applies");
3435 assert_eq!(
3436 changed,
3437 CALM_PRESET_FIELDS
3438 .iter()
3439 .map(|(k, _)| *k)
3440 .collect::<Vec<_>>()
3441 );
3442
3443 assert!(settings.calm_mode);
3444 assert_eq!(settings.tool_collapse_mode, "calm");
3445 assert_eq!(settings.transcript_spacing, "compact");
3446 assert!(settings.low_motion);
3447 assert!(!settings.fancy_animations);
3448 assert!(!settings.show_tool_details);
3449 // Calm does not override the user's reasoning preference.
3450 assert!(!settings.show_thinking);
3451 }
3452
3453 #[test]
3454 fn default_settings_use_comfortable_transcript_spacing() {
3455 let settings = Settings::default();
3456 assert!(settings.calm_mode);
3457 assert!(!settings.show_tool_details);
3458 assert!(!settings.low_motion);
3459 assert!(settings.fancy_animations);
3460 assert_eq!(settings.transcript_spacing, "comfortable");
3461 assert_eq!(settings.tool_collapse_mode, "compact");
3462 // Thinking is opt-in so the transcript stays focused on the chat.
3463 assert!(!settings.show_thinking);
3464 }
3465
3466 #[test]
3467 fn behavioral_tip_impressions_are_backward_compatible_and_persist_when_seen() {
3468 let default_body = toml::to_string_pretty(&Settings::default()).expect("serialize");
3469 assert!(!default_body.contains("behavioral_tip_impressions"));
3470
3471 let mut settings = Settings::default();
3472 settings
3473 .behavioral_tip_impressions
3474 .insert("planning_mode".to_string(), 1);
3475 let body = toml::to_string_pretty(&settings).expect("serialize");
3476 let restored: Settings = toml::from_str(&body).expect("restore settings");
3477 assert_eq!(
3478 restored
3479 .behavioral_tip_impressions
3480 .get("planning_mode")
3481 .copied(),
3482 Some(1)
3483 );
3484 }
3485
3486 #[test]
3487 fn contextual_tips_default_on_and_round_trip_opt_out() {
3488 let old: Settings = toml::from_str("").unwrap();
3489 assert!(old.contextual_tips);
3490 let mut settings = old;
3491 settings.set("contextual_tips", "off").unwrap();
3492 let restored: Settings = toml::from_str(&toml::to_string(&settings).unwrap()).unwrap();
3493 assert!(!restored.contextual_tips);
3494 }
3495
3496 #[test]
3497 fn settings_save_preserves_malformed_document_instead_of_fallback_defaults() {
3498 let temp = tempfile::tempdir().unwrap();
3499 let path = temp.path().join("settings.toml");
3500 let malformed = "theme = [private_fixture_payload\n";
3501 std::fs::write(&path, malformed).unwrap();
3502 let mut settings =
3503 Settings::load_persisted_from_candidates(Some(path.clone()), None, None).unwrap();
3504 assert!(settings.load_error.is_some());
3505 // Impression writers use this same save boundary as the opt-out.
3506 settings
3507 .behavioral_tip_impressions
3508 .insert("planning_mode".into(), 1);
3509 let error = settings.save_to_path(&path).unwrap_err().to_string();
3510 assert_eq!(std::fs::read_to_string(path).unwrap(), malformed);
3511 assert!(!error.contains("private_fixture_payload"));
3512 }
3513
3514 #[test]
3515 fn plugin_dismissals_are_additive_and_omitted_until_used() {
3516 let old = toml::to_string_pretty(&Settings::default()).unwrap();
3517 assert!(!old.contains("dismissed_plugin_suggestions"));
3518 let mut settings: Settings = toml::from_str(&old).unwrap();
3519 assert!(settings.dismissed_plugin_suggestions.is_empty());
3520 settings
3521 .dismissed_plugin_suggestions
3522 .insert("supabase".into());
3523 let encoded = toml::to_string_pretty(&settings).unwrap();
3524 let decoded: Settings = toml::from_str(&encoded).unwrap();
3525 assert!(decoded.dismissed_plugin_suggestions.contains("supabase"));
3526 }
3527
3528 #[test]
3529 fn footer_hint_uses_are_backward_compatible_and_persist_when_recorded() {
3530 let default_body = toml::to_string_pretty(&Settings::default()).expect("serialize");
3531 assert!(!default_body.contains("footer_hint_uses"));
3532
3533 let mut settings = Settings::default();
3534 settings
3535 .footer_hint_uses
3536 .insert("permission_cycle".to_string(), 2);
3537 let body = toml::to_string_pretty(&settings).expect("serialize");
3538 let restored: Settings = toml::from_str(&body).expect("restore settings");
3539 assert_eq!(
3540 restored.footer_hint_uses.get("permission_cycle").copied(),
3541 Some(2)
3542 );
3543 }
3544
3545 #[test]
3546 fn apply_preset_rejects_unknown_name() {
3547 let mut settings = Settings::default();
3548 let err = settings.apply_preset("turbo").expect_err("unknown preset");
3549 assert!(err.to_string().contains("Unknown preset"));
3550 assert!(preset_fields("calm").is_some());
3551 assert!(preset_fields("turbo").is_none());
3552 }
3553
3554 #[test]
3555 fn default_settings_keep_auto_compact_as_unset_fallback() {
3556 let settings = Settings::default();
3557 // The persisted fallback remains false so a missing settings file does
3558 // not look like an explicit user preference. Startup resolves the
3559 // runtime default from the active model window unless the file contains
3560 // `auto_compact`.
3561 assert!(!settings.auto_compact);
3562 assert_eq!(settings.auto_compact_threshold_percent, 80.0);
3563 assert!(!settings.auto_compact_explicit);
3564 }
3565
3566 #[test]
3567 fn auto_compact_remains_explicitly_configurable() {
3568 let mut settings = Settings::default();
3569 settings.set("auto_compact", "on").expect("enable");
3570 assert!(settings.auto_compact);
3571 assert!(settings.auto_compact_explicit);
3572 settings.set("auto_compact", "off").expect("disable");
3573 assert!(!settings.auto_compact);
3574 }
3575
3576 #[test]
3577 fn unrelated_save_does_not_materialize_implicit_auto_compact_defaults() {
3578 let tmp = tempfile::tempdir().expect("tempdir");
3579 let path = tmp.path().join("settings.toml");
3580 let settings = Settings {
3581 calm_mode: false,
3582 ..Settings::default()
3583 };
3584
3585 settings.save_to_path(&path).expect("save settings");
3586
3587 let body = std::fs::read_to_string(&path).expect("read settings");
3588 let document = toml::from_str::<toml::Value>(&body).expect("parse settings");
3589 assert!(!auto_compact_explicitly_configured_in_document(&document));
3590 let reloaded = Settings::load_persisted_from_candidates(Some(path), None, None)
3591 .expect("reload settings");
3592 assert!(!reloaded.auto_compact_explicit);
3593 assert!(!reloaded.auto_compact);
3594 assert!(!reloaded.calm_mode);
3595 }
3596
3597 #[test]
3598 fn explicit_auto_compact_off_survives_save_and_reload() {
3599 let tmp = tempfile::tempdir().expect("tempdir");
3600 let path = tmp.path().join("settings.toml");
3601 let mut settings = Settings::default();
3602 settings.set("auto_compact", "off").expect("disable");
3603
3604 settings.save_to_path(&path).expect("save settings");
3605
3606 assert!(auto_compact_explicitly_configured_from_candidates((
3607 Some(path.clone()),
3608 None,
3609 None,
3610 )));
3611 let reloaded = Settings::load_persisted_from_candidates(Some(path), None, None)
3612 .expect("reload settings");
3613 assert!(reloaded.auto_compact_explicit);
3614 assert!(!reloaded.auto_compact);
3615 }
3616
3617 #[test]
3618 fn auto_compact_threshold_is_validated() {
3619 let mut settings = Settings::default();
3620 settings
3621 .set("auto_compact_threshold", "65%")
3622 .expect("threshold");
3623 assert!(settings.auto_compact, "a threshold expresses enable intent");
3624 assert_eq!(settings.auto_compact_threshold_percent, 65.0);
3625 assert!(settings.auto_compact_explicit);
3626 assert!(settings.set("auto_compact_threshold", "9").is_err());
3627 assert!(settings.set("auto_compact_threshold", "101").is_err());
3628 }
3629
3630 #[test]
3631 fn threshold_only_persisted_config_enables_auto_compaction() {
3632 let tmp = tempfile::tempdir().expect("tempdir");
3633 let path = tmp.path().join("settings.toml");
3634 std::fs::write(&path, "auto_compact_threshold_percent = 65\n").expect("settings");
3635
3636 let loaded = Settings::load_persisted_from_candidates(Some(path.clone()), None, None)
3637 .expect("load threshold-only settings");
3638
3639 assert!(loaded.auto_compact);
3640 assert!(loaded.auto_compact_explicit);
3641 assert_eq!(loaded.auto_compact_threshold_percent, 65.0);
3642 assert!(auto_compact_explicitly_configured_from_candidates((
3643 Some(path),
3644 None,
3645 None,
3646 )));
3647 }
3648
3649 #[test]
3650 fn explicit_auto_compact_off_overrides_a_persisted_threshold() {
3651 let tmp = tempfile::tempdir().expect("tempdir");
3652 let path = tmp.path().join("settings.toml");
3653 std::fs::write(
3654 &path,
3655 "auto_compact = false\nauto_compact_threshold_percent = 65\n",
3656 )
3657 .expect("settings");
3658
3659 let loaded = Settings::load_persisted_from_candidates(Some(path.clone()), None, None)
3660 .expect("load explicit opt-out");
3661
3662 assert!(!loaded.auto_compact);
3663 assert!(loaded.auto_compact_explicit);
3664 assert!(auto_compact_explicitly_configured_from_candidates((
3665 Some(path),
3666 None,
3667 None,
3668 )));
3669 }
3670
3671 #[test]
3672 fn default_settings_show_footer_water_strip() {
3673 let settings = Settings::default();
3674 assert!(
3675 settings.fancy_animations,
3676 "underwater presentation is the default"
3677 );
3678 assert!(!settings.low_motion);
3679 assert_eq!(settings.transcript_spacing, "comfortable");
3680 }
3681
3682 #[test]
3683 fn retired_launch_screen_setting_is_accepted_and_dropped_on_save() {
3684 let tmp = tempfile::tempdir().expect("tempdir");
3685 let path = tmp.path().join("settings.toml");
3686 std::fs::write(&path, "launch_screen = false\n").expect("legacy settings");
3687
3688 let settings = Settings::load_persisted_from_candidates(Some(path.clone()), None, None)
3689 .expect("legacy setting must remain readable");
3690 settings
3691 .save_to_path(&path)
3692 .expect("save normalized settings");
3693
3694 let saved = std::fs::read_to_string(&path).expect("read normalized settings");
3695 assert!(
3696 !saved.contains("launch_screen"),
3697 "the retired setting must not be written back: {saved}"
3698 );
3699 }
3700
3701 #[test]
3702 fn legacy_sidebar_focus_migrates_to_rail_panel_and_placement() {
3703 let migrate = |focus: &str| {
3704 let mut settings = Settings {
3705 sidebar_focus: focus.to_string(),
3706 ..Settings::default()
3707 };
3708 migrate_sidebar_settings_to_rail(&mut settings);
3709 settings
3710 };
3711
3712 assert_eq!(migrate("agents").rail_panel, "agents");
3713 assert_eq!(migrate("subagents").rail_panel, "agents");
3714 assert_eq!(migrate("context").rail_panel, "context");
3715 assert_eq!(migrate("session").rail_panel, "context");
3716 assert_eq!(migrate("tasks").rail_panel, "tasks");
3717 assert_eq!(migrate("activity").rail_panel, "tasks");
3718 assert_eq!(migrate("pinned").rail_panel, "pinned");
3719 assert_eq!(migrate("work").rail_panel, "pinned");
3720 // `auto` is the shipped default for `sidebar_focus`, so this arm is
3721 // the effective default for every upgrading user — it must land on
3722 // the panel that hides itself when there is nothing to show, not on
3723 // the always-on pinned strip.
3724 assert_eq!(migrate("auto").rail_panel, "tasks");
3725 // A hidden sidebar becomes rail placement off.
3726 let hidden = migrate("hidden");
3727 assert_eq!(hidden.work_surface_placement, "off");
3728 // #5141's pinned sessions panel carries forward as the first-class
3729 // sessions rail.
3730 assert!(migrate("sessions").sessions_rail);
3731 assert!(migrate("sessions_rail").sessions_rail);
3732 // An explicit `rail_panel = "tasks"` in the document wins over the
3733 // auto→pinned migration even though "tasks" is the default value.
3734 let mut explicit = Settings {
3735 sidebar_focus: "auto".to_string(),
3736 rail_panel: "tasks".to_string(),
3737 rail_panel_explicit: true,
3738 ..Settings::default()
3739 };
3740 migrate_sidebar_settings_to_rail(&mut explicit);
3741 assert_eq!(explicit.rail_panel, "tasks");
3742 // Placement panels keep their placement when the rail hides.
3743 let mut bottom = Settings {
3744 sidebar_focus: "hidden".to_string(),
3745 work_surface_placement: "bottom".to_string(),
3746 work_surface_placement_explicit: true,
3747 ..Settings::default()
3748 };
3749 migrate_sidebar_settings_to_rail(&mut bottom);
3750 // Bottom is a valid explicit placement now, so migration keeps it.
3751 assert_eq!(bottom.work_surface_placement, "bottom");
3752 }
3753
3754 #[test]
3755 fn legacy_sidebar_width_maps_to_side_columns_and_new_keys_win() {
3756 let mut settings = Settings {
3757 sidebar_width_percent: 40,
3758 ..Settings::default()
3759 };
3760 migrate_sidebar_settings_to_rail(&mut settings);
3761 assert_eq!(settings.work_surface_side_width, 48);
3762
3763 // The default percent leaves the default side width alone.
3764 let mut settings = Settings::default();
3765 migrate_sidebar_settings_to_rail(&mut settings);
3766 assert_eq!(settings.work_surface_side_width, 30);
3767
3768 // An explicit rail panel wins over the migrated sidebar focus.
3769 let mut settings = Settings {
3770 sidebar_focus: "context".to_string(),
3771 rail_panel: "agents".to_string(),
3772 ..Settings::default()
3773 };
3774 migrate_sidebar_settings_to_rail(&mut settings);
3775 assert_eq!(settings.rail_panel, "agents");
3776 }
3777
3778 #[test]
3779 fn reasoning_effort_setting_normalizes_and_clears() {
3780 let mut settings = Settings::default();
3781 // `xhigh` and `ultra` are their own rungs since the thinking ladder,
3782 // so normalizing collapses spellings *within* a tier instead of
3783 // folding the top three tiers into `max`.
3784 for (input, stored) in [
3785 ("xhigh", "xhigh"),
3786 ("ultracode", "ultra"),
3787 ("maximum", "max"),
3788 // Slice 4, D3: `minimal` is a real rung with its own spelling, so it
3789 // round-trips instead of being folded onto `low`.
3790 ("minimal", "minimal"),
3791 ("minimum", "low"),
3792 ("light", "low"),
3793 ] {
3794 settings
3795 .set("reasoning_effort", input)
3796 .unwrap_or_else(|error| panic!("normalize {input}: {error}"));
3797 assert_eq!(settings.reasoning_effort.as_deref(), Some(stored));
3798 }
3799 settings
3800 .set("reasoning_effort", "default")
3801 .expect("clear effort");
3802 assert!(settings.reasoning_effort.is_none());
3803 }
3804
3805 #[test]
3806 fn paste_burst_detection_is_configurable_independent_of_bracketed_paste() {
3807 let mut settings = Settings::default();
3808 assert!(settings.bracketed_paste);
3809 assert!(settings.paste_burst_detection);
3810
3811 settings
3812 .set("paste_burst_detection", "off")
3813 .expect("disable paste burst fallback");
3814 assert!(settings.bracketed_paste);
3815 assert!(!settings.paste_burst_detection);
3816
3817 settings
3818 .set("bracketed_paste", "off")
3819 .expect("disable bracketed paste");
3820 assert!(!settings.bracketed_paste);
3821 assert!(!settings.paste_burst_detection);
3822 }
3823
3824 #[test]
3825 fn mention_completion_caps_are_configurable() {
3826 let mut settings = Settings::default();
3827 assert_eq!(settings.mention_menu_limit, 128);
3828 assert_eq!(settings.mention_walk_depth, 10);
3829 assert_eq!(settings.mention_menu_behavior, "fuzzy");
3830 settings
3831 .set("mention_menu_limit", "256")
3832 .expect("set mention menu limit");
3833 settings
3834 .set("mention_walk_depth", "0")
3835 .expect("allow unlimited walk depth");
3836 settings
3837 .set("mention_menu_behavior", "browser")
3838 .expect("set mention menu behavior");
3839
3840 assert_eq!(settings.mention_menu_limit, 256);
3841 assert_eq!(settings.mention_walk_depth, 0);
3842 assert_eq!(settings.mention_menu_behavior, "browser");
3843
3844 let err = settings
3845 .set("mention_walk_depth", "deep")
3846 .expect_err("non-numeric depth should fail");
3847 assert!(err.to_string().contains("invalid mention_walk_depth"));
3848
3849 let err = settings
3850 .set("mention_menu_behavior", "random")
3851 .expect_err("unknown mention behavior should fail");
3852 assert!(err.to_string().contains("invalid mention_menu_behavior"));
3853 }
3854
3855 #[test]
3856 fn locale_normalizes_supported_values_and_rejects_unknowns() {
3857 let mut settings = Settings::default();
3858 for (input, expected) in [
3859 ("ja_JP.UTF-8", "ja"),
3860 ("zh-CN", "zh-Hans"),
3861 ("zh-TW", "zh-Hant"),
3862 ("zh-Hant", "zh-Hant"),
3863 ("es-MX", "es-419"),
3864 ("vi_VN.UTF-8", "vi"),
3865 ("ko-KR", "ko"),
3866 ("ca-ES", "ca"),
3867 ("de_DE.UTF-8", "de"),
3868 ("fr-FR", "fr"),
3869 ("id-ID", "id"),
3870 ("hi_IN.UTF-8", "hi"),
3871 ("ru-RU", "ru"),
3872 ("uk_UA.UTF-8", "uk"),
3873 ] {
3874 settings
3875 .set("locale", input)
3876 .unwrap_or_else(|err| panic!("set locale {input}: {err}"));
3877 assert_eq!(settings.locale, expected);
3878 }
3879
3880 settings.set("language", "pt-PT").expect("set pt fallback");
3881 assert_eq!(settings.locale, "pt-BR");
3882
3883 let err = settings
3884 .set("locale", "ar")
3885 .expect_err("Arabic is planned, not shipped");
3886 assert!(err.to_string().contains("invalid locale"));
3887 }
3888
3889 #[test]
3890 fn default_settings_resolve_to_the_underwater_theme() {
3891 // The fresh-install default is the Underwater theme, end to end from
3892 // `Settings::default()` through theme resolution.
3893 let settings = Settings::default();
3894 assert_eq!(settings.theme, "underwater");
3895 let (name, id, theme) = codewhale_palette::resolve_theme_setting(&settings.theme, None)
3896 .expect("default resolves");
3897 assert_eq!(id, codewhale_palette::ThemeId::Underwater);
3898 assert_eq!(name, "underwater");
3899 assert_eq!(theme.name, "underwater");
3900 let saved: Settings = toml::from_str("theme = \"shoreline\"\n").expect("saved theme");
3901 assert_eq!(
3902 saved.theme, "shoreline",
3903 "upgrades preserve an explicit choice"
3904 );
3905 }
3906
3907 #[test]
3908 fn theme_normalizes_supported_values_and_rejects_unknowns() {
3909 let mut settings = Settings::default();
3910 assert_eq!(settings.theme, "underwater");
3911
3912 settings
3913 .set("theme", "charcoal")
3914 .expect("set charcoal alternative");
3915 assert_eq!(settings.theme, "shoreline");
3916
3917 settings.set("theme", "grayscale").expect("set grayscale");
3918 assert_eq!(settings.theme, "grayscale");
3919
3920 settings.set("ui_theme", "black-white").expect("set alias");
3921 assert_eq!(settings.theme, "grayscale");
3922
3923 settings.set("theme", "whale").expect("set dark alias");
3924 assert_eq!(settings.theme, "dark");
3925
3926 settings
3927 .set("theme", "tokyonight")
3928 .expect("set community theme alias");
3929 assert_eq!(settings.theme, "tokyo-night");
3930
3931 settings
3932 .set("theme", "solarized")
3933 .expect("set solarized alias");
3934 assert_eq!(settings.theme, "solarized-light");
3935
3936 settings
3937 .set("theme", "custom:Ocean_1")
3938 .expect("custom selector validation must not depend on the file system");
3939 assert_eq!(settings.theme, "custom:ocean_1");
3940
3941 let err = settings
3942 .set("theme", "nord")
3943 .expect_err("unknown theme should fail");
3944 assert!(err.to_string().contains("invalid theme"));
3945 }
3946
3947 #[test]
3948 fn background_color_normalizes_hex_and_accepts_default() {
3949 let mut settings = Settings::default();
3950 settings
3951 .set("background_color", "#1A1b26")
3952 .expect("set custom background");
3953 assert_eq!(settings.background_color.as_deref(), Some("#1a1b26"));
3954
3955 settings
3956 .set("background", "default")
3957 .expect("reset custom background");
3958 assert_eq!(settings.background_color, None);
3959 }
3960
3961 #[test]
3962 fn background_color_rejects_invalid_hex() {
3963 let mut settings = Settings::default();
3964 let err = settings
3965 .set("background_color", "#123")
3966 .expect_err("short hex should fail");
3967 assert!(err.to_string().contains("invalid background_color"));
3968 }
3969
3970 #[test]
3971 fn cost_currency_normalizes_yuan_aliases_and_rejects_unknowns() {
3972 let mut settings = Settings::default();
3973 assert_eq!(settings.cost_currency, "usd");
3974
3975 settings.set("cost_currency", "yuan").expect("set yuan");
3976 assert_eq!(settings.cost_currency, "cny");
3977
3978 settings.set("currency", "rmb").expect("set rmb");
3979 assert_eq!(settings.cost_currency, "cny");
3980
3981 let err = settings
3982 .set("cost_currency", "eur")
3983 .expect_err("unsupported currency");
3984 assert!(err.to_string().contains("invalid cost currency"));
3985 }
3986
3987 #[test]
3988 fn context_panel_is_configurable() {
3989 let mut settings = Settings::default();
3990 assert!(!settings.context_panel);
3991
3992 settings
3993 .set("context_panel", "on")
3994 .expect("enable context panel");
3995 assert!(settings.context_panel);
3996
3997 settings
3998 .set("session_panel", "off")
3999 .expect("disable context panel via alias");
4000 assert!(!settings.context_panel);
4001 }
4002
4003 #[test]
4004 fn tool_collapse_mode_is_configurable() {
4005 let mut settings = Settings::default();
4006 assert_eq!(settings.tool_collapse_mode, "compact");
4007
4008 settings
4009 .set("tool_collapse", "expanded")
4010 .expect("expanded mode");
4011 assert_eq!(settings.tool_collapse_mode, "expanded");
4012
4013 settings.set("collapse", "calm-only").expect("calm alias");
4014 assert_eq!(settings.tool_collapse_mode, "calm");
4015
4016 settings.set("collapse", "off").expect("off alias");
4017 assert_eq!(settings.tool_collapse_mode, "expanded");
4018
4019 // Issue #3256 proposes `collapsed` as the default verbosity name;
4020 // accept it (and the bare verb) as an alias of the canonical `compact`.
4021 settings
4022 .set("tool_collapse", "collapsed")
4023 .expect("collapsed alias");
4024 assert_eq!(settings.tool_collapse_mode, "compact");
4025 settings.set("tool_collapse", "expanded").expect("reset");
4026 settings
4027 .set("tool_collapse", "collapse")
4028 .expect("collapse alias");
4029 assert_eq!(settings.tool_collapse_mode, "compact");
4030
4031 let err = settings
4032 .set("tool_collapse", "mystery")
4033 .expect_err("invalid collapse mode");
4034 assert!(err.to_string().contains("invalid tool collapse mode"));
4035 }
4036
4037 #[test]
4038 fn tool_collapse_threshold_is_not_a_settings_key() {
4039 // #3256: rollup min-run size stays a fixed runtime constant (3), not a
4040 // user setting — reject any accidental /set surface for it.
4041 let mut settings = Settings::default();
4042 let err = settings
4043 .set("tool_collapse_threshold", "5")
4044 .expect_err("threshold must not be configurable");
4045 assert!(
4046 err.to_string().contains("Unknown setting")
4047 || err.to_string().contains("unknown setting")
4048 || err.to_string().contains("Failed to update"),
4049 "unexpected error: {err}"
4050 );
4051 assert_eq!(settings.tool_collapse_mode, "compact");
4052 assert!(!settings.show_tool_details);
4053 }
4054
4055 #[test]
4056 fn display_localizes_header_and_config_file_label() {
4057 let settings = Settings::default();
4058 let en = settings.display(codewhale_localization::Locale::En);
4059 assert!(en.contains("Settings:"), "english header missing:\n{en}");
4060 assert!(
4061 en.contains("Config file:"),
4062 "english config label missing:\n{en}"
4063 );
4064
4065 let zh = settings.display(codewhale_localization::Locale::ZhHans);
4066 assert!(zh.contains("设置"), "chinese header missing:\n{zh}");
4067 assert!(
4068 zh.contains("配置文件"),
4069 "chinese config label missing:\n{zh}"
4070 );
4071 }
4072
4073 #[test]
4074 fn display_does_not_present_archived_route_preferences_as_current_defaults() {
4075 let settings = Settings {
4076 default_provider: Some("zai".to_string()),
4077 default_model: Some("deepseek-v4-pro".to_string()),
4078 provider_models: Some(std::collections::HashMap::from([
4079 ("zai".to_string(), "GLM-5.2".to_string()),
4080 ("deepseek".to_string(), "deepseek-v4-flash".to_string()),
4081 ])),
4082 ..Settings::default()
4083 };
4084
4085 let display = settings.display(codewhale_localization::Locale::En);
4086
4087 assert!(display.contains("model defaults: config.toml (use /config)"));
4088 for archived in [
4089 "deepseek_fallback:",
4090 "default_provider:",
4091 "provider_models:",
4092 "default_model:",
4093 "GLM-5.2",
4094 "deepseek-v4-pro",
4095 "deepseek-v4-flash",
4096 ] {
4097 assert!(
4098 !display.contains(archived),
4099 "archived value shown as current: {display}"
4100 );
4101 }
4102 }
4103
4104 #[test]
4105 fn archived_model_preferences_survive_serialization_but_reject_new_settings_writes() {
4106 let mut settings: Settings = toml::from_str(
4107 "default_provider = 'zai'\ndefault_model = 'deepseek-v4-pro'\n[provider_models]\nzai = 'GLM-5.3'\n",
4108 ).expect("legacy preferences");
4109 let before = toml::to_string(&settings).expect("legacy snapshot");
4110
4111 for key in ["model", "default_model"] {
4112 let error = settings
4113 .set(key, "deepseek-v4-flash")
4114 .expect_err("canonical config owns models");
4115 assert!(error.to_string().contains("/config model"));
4116 }
4117
4118 assert_eq!(
4119 toml::to_string(&settings).expect("unchanged legacy snapshot"),
4120 before
4121 );
4122 let restored: Settings = toml::from_str(&before).expect("preserved migration inputs");
4123 assert_eq!(restored.default_provider.as_deref(), Some("zai"));
4124 assert_eq!(restored.default_model.as_deref(), Some("deepseek-v4-pro"));
4125 assert_eq!(
4126 restored
4127 .provider_models
4128 .as_ref()
4129 .and_then(|models| models.get("zai"))
4130 .map(String::as_str),
4131 Some("GLM-5.3")
4132 );
4133 }
4134
4135 #[test]
4136 fn legacy_enabled_models_table_still_loads_and_round_trips() {
4137 let settings: Settings = toml::from_str(
4138 r#"
4139 [enabled_models]
4140 zai = ["GLM-5.2", "GLM-5.3"]
4141 "#,
4142 )
4143 .expect("legacy enabled_models loads");
4144 assert!(settings.provider_models.is_none());
4145 assert_eq!(
4146 settings
4147 .enabled_models
4148 .as_ref()
4149 .and_then(|models| models.get("zai")),
4150 Some(&vec!["GLM-5.2".to_string(), "GLM-5.3".to_string()])
4151 );
4152 let encoded = toml::to_string(&settings).expect("serialize enabled models");
4153 let decoded: Settings = toml::from_str(&encoded).expect("deserialize enabled models");
4154 assert_eq!(decoded.enabled_models, settings.enabled_models);
4155 }
4156
4157 /// Tests that mutate process-global `NO_ANIMATIONS` serialise
4158 /// through this guard so the cargo parallel runner doesn't
4159 /// observe interleaved overrides. Uses the process-wide test env
4160 /// lock so this serializes with the TERM_PROGRAM tests too —
4161 /// otherwise a `NO_ANIMATIONS=1` leak from this test family can
4162 /// flip a concurrent `TERM_PROGRAM=iTerm` test's `low_motion`
4163 /// assertion through the shared `apply_env_overrides` path.
4164 fn no_animations_test_guard() -> crate::test_support::TestEnvLock {
4165 crate::test_support::lock_test_env()
4166 }
4167
4168 #[test]
4169 fn no_animations_env_forces_low_motion_on() {
4170 let _g = no_animations_test_guard();
4171 // SAFETY: tests in this group serialise through the guard.
4172 unsafe {
4173 std::env::set_var("NO_ANIMATIONS", "1");
4174 }
4175 let mut settings = animated_settings();
4176 assert!(!settings.low_motion, "default is animated");
4177 assert!(settings.fancy_animations, "default shows the water strip");
4178 settings.apply_env_overrides();
4179 assert!(settings.low_motion, "NO_ANIMATIONS=1 forces low_motion");
4180 assert!(
4181 !settings.fancy_animations,
4182 "NO_ANIMATIONS=1 keeps fancy off"
4183 );
4184 // SAFETY: cleanup under the guard.
4185 unsafe {
4186 std::env::remove_var("NO_ANIMATIONS");
4187 }
4188 }
4189
4190 #[test]
4191 fn no_animations_env_overrides_user_opt_in() {
4192 let _g = no_animations_test_guard();
4193 // SAFETY: serialised by the guard.
4194 unsafe {
4195 std::env::set_var("NO_ANIMATIONS", "true");
4196 }
4197 // User had explicitly opted into fancy animations on disk.
4198 let mut settings = Settings {
4199 fancy_animations: true,
4200 ..Settings::default()
4201 };
4202 settings.apply_env_overrides();
4203 assert!(
4204 !settings.fancy_animations,
4205 "platform NO_ANIMATIONS overrides user-opt-in fancy_animations"
4206 );
4207 assert!(settings.low_motion);
4208 // SAFETY: cleanup under the guard.
4209 unsafe {
4210 std::env::remove_var("NO_ANIMATIONS");
4211 }
4212 }
4213
4214 #[test]
4215 fn no_animations_env_recognises_truthy_spellings_only() {
4216 let _g = no_animations_test_guard();
4217 let prev_wt_session = std::env::var_os("WT_SESSION");
4218 let prev_tmux = std::env::var_os("TMUX");
4219 let prev_sty = std::env::var_os("STY");
4220 let prev_term_program = std::env::var_os("TERM_PROGRAM");
4221 let prev_term = std::env::var_os("TERM");
4222 let prev_ssh_client = std::env::var_os("SSH_CLIENT");
4223 let prev_ssh_tty = std::env::var_os("SSH_TTY");
4224 let prev_tilix_id = std::env::var_os("TILIX_ID");
4225 let prev_terminator_uuid = std::env::var_os("TERMINATOR_UUID");
4226
4227 // The test is about NO_ANIMATIONS only. On Windows CI, an unmarked
4228 // console host now independently enables low_motion, so mark the host
4229 // as non-legacy while checking falsy spellings.
4230 // Clear multiplexer markers for the same reason: they also force
4231 // low_motion independently of NO_ANIMATIONS.
4232 // Clear TERM_PROGRAM, SSH, and other terminal-specific variables as they
4233 // also force low_motion independently of NO_ANIMATIONS.
4234 // SAFETY: serialised by the guard.
4235 unsafe {
4236 std::env::remove_var("TMUX");
4237 std::env::remove_var("STY");
4238 std::env::remove_var("TERM_PROGRAM");
4239 std::env::remove_var("TERM");
4240 std::env::remove_var("SSH_CLIENT");
4241 std::env::remove_var("SSH_TTY");
4242 std::env::remove_var("TILIX_ID");
4243 std::env::remove_var("TERMINATOR_UUID");
4244 }
4245 #[cfg(windows)]
4246 unsafe {
4247 std::env::set_var("WT_SESSION", "test");
4248 }
4249 for truthy in ["1", "true", "True", "YES", "on"] {
4250 // SAFETY: serialised by the guard.
4251 unsafe {
4252 std::env::set_var("NO_ANIMATIONS", truthy);
4253 }
4254 let mut s = animated_settings();
4255 s.apply_env_overrides();
4256 assert!(s.low_motion, "{truthy:?} should be truthy");
4257 }
4258 for falsy in ["0", "false", "no", "off", ""] {
4259 // SAFETY: serialised by the guard.
4260 unsafe {
4261 std::env::set_var("NO_ANIMATIONS", falsy);
4262 }
4263 let mut s = animated_settings();
4264 s.apply_env_overrides();
4265 assert!(!s.low_motion, "{falsy:?} should be falsy");
4266 }
4267 // SAFETY: cleanup under the guard.
4268 unsafe {
4269 std::env::remove_var("NO_ANIMATIONS");
4270 match prev_wt_session {
4271 Some(v) => std::env::set_var("WT_SESSION", v),
4272 None => std::env::remove_var("WT_SESSION"),
4273 }
4274 match prev_tmux {
4275 Some(v) => std::env::set_var("TMUX", v),
4276 None => std::env::remove_var("TMUX"),
4277 }
4278 match prev_sty {
4279 Some(v) => std::env::set_var("STY", v),
4280 None => std::env::remove_var("STY"),
4281 }
4282 match prev_term_program {
4283 Some(v) => std::env::set_var("TERM_PROGRAM", v),
4284 None => std::env::remove_var("TERM_PROGRAM"),
4285 }
4286 match prev_term {
4287 Some(v) => std::env::set_var("TERM", v),
4288 None => std::env::remove_var("TERM"),
4289 }
4290 match prev_ssh_client {
4291 Some(v) => std::env::set_var("SSH_CLIENT", v),
4292 None => std::env::remove_var("SSH_CLIENT"),
4293 }
4294 match prev_ssh_tty {
4295 Some(v) => std::env::set_var("SSH_TTY", v),
4296 None => std::env::remove_var("SSH_TTY"),
4297 }
4298 match prev_tilix_id {
4299 Some(v) => std::env::set_var("TILIX_ID", v),
4300 None => std::env::remove_var("TILIX_ID"),
4301 }
4302 match prev_terminator_uuid {
4303 Some(v) => std::env::set_var("TERMINATOR_UUID", v),
4304 None => std::env::remove_var("TERMINATOR_UUID"),
4305 }
4306 }
4307 }
4308
4309 /// Serialise tests that mutate `TERM_PROGRAM` through this guard.
4310 /// Uses the process-wide test env lock so this serializes not just
4311 /// with itself but with every other env-mutating test in the suite
4312 /// — otherwise a concurrent test that calls `animated_settings()`
4313 /// can read whatever value our two `set_var`s have raced into the
4314 /// env at that instant.
4315 fn term_program_test_guard() -> crate::test_support::TestEnvLock {
4316 crate::test_support::lock_test_env()
4317 }
4318
4319 #[test]
4320 fn vscode_uses_calm_rendering_without_changing_text_cadence() {
4321 let _g = term_program_test_guard();
4322 let prev = std::env::var_os("TERM_PROGRAM");
4323 // SAFETY: serialised by the guard.
4324 unsafe {
4325 std::env::set_var("TERM_PROGRAM", "vscode");
4326 }
4327 let mut settings = animated_settings();
4328 assert!(!settings.low_motion, "default is animated");
4329 settings.apply_env_overrides();
4330 assert!(
4331 settings.low_motion,
4332 "TERM_PROGRAM=vscode must disable decorative motion"
4333 );
4334 assert!(!settings.fancy_animations);
4335 assert!(
4336 settings.constrained_frame_rate,
4337 "TERM_PROGRAM=vscode should cap redraws without changing animation semantics"
4338 );
4339 // SAFETY: cleanup under the guard.
4340 unsafe {
4341 match prev {
4342 Some(v) => std::env::set_var("TERM_PROGRAM", v),
4343 None => std::env::remove_var("TERM_PROGRAM"),
4344 }
4345 }
4346 }
4347
4348 #[test]
4349 fn ghostty_term_program_keeps_full_motion_without_the_legacy_30_fps_cap() {
4350 let _g = term_program_test_guard();
4351 // Neutralize the SSH markers: production intentionally caps motion
4352 // over SSH, and the suite routinely runs inside one.
4353 let _ssh_client = crate::test_support::EnvVarGuard::remove("SSH_CLIENT");
4354 let _ssh_connection = crate::test_support::EnvVarGuard::remove("SSH_CONNECTION");
4355 let _ssh_tty = crate::test_support::EnvVarGuard::remove("SSH_TTY");
4356 let prev = std::env::var_os("TERM_PROGRAM");
4357 // SAFETY: serialised by the guard.
4358 unsafe {
4359 std::env::set_var("TERM_PROGRAM", "Ghostty");
4360 }
4361 let mut settings = animated_settings();
4362 assert!(!settings.low_motion, "default is animated");
4363 settings.apply_env_overrides();
4364 assert!(!settings.low_motion);
4365 assert!(settings.fancy_animations);
4366 assert!(!settings.constrained_frame_rate);
4367 // SAFETY: cleanup under the guard.
4368 unsafe {
4369 match prev {
4370 Some(v) => std::env::set_var("TERM_PROGRAM", v),
4371 None => std::env::remove_var("TERM_PROGRAM"),
4372 }
4373 }
4374 }
4375
4376 #[test]
4377 fn ghostty_term_fallback_keeps_full_motion_without_the_legacy_30_fps_cap() {
4378 let _g = term_program_test_guard();
4379 // Neutralize the SSH markers: production intentionally caps motion
4380 // over SSH, and the suite routinely runs inside one.
4381 let _ssh_client = crate::test_support::EnvVarGuard::remove("SSH_CLIENT");
4382 let _ssh_connection = crate::test_support::EnvVarGuard::remove("SSH_CONNECTION");
4383 let _ssh_tty = crate::test_support::EnvVarGuard::remove("SSH_TTY");
4384 let prev_program = std::env::var_os("TERM_PROGRAM");
4385 let prev_term = std::env::var_os("TERM");
4386 // SAFETY: serialised by the guard.
4387 unsafe {
4388 std::env::remove_var("TERM_PROGRAM");
4389 std::env::set_var("TERM", "xterm-ghostty");
4390 }
4391 let mut settings = Settings::default();
4392 settings.apply_env_overrides();
4393 assert!(!settings.low_motion);
4394 assert!(settings.fancy_animations);
4395 assert!(!settings.constrained_frame_rate);
4396 // SAFETY: cleanup under the guard.
4397 unsafe {
4398 match prev_program {
4399 Some(v) => std::env::set_var("TERM_PROGRAM", v),
4400 None => std::env::remove_var("TERM_PROGRAM"),
4401 }
4402 match prev_term {
4403 Some(v) => std::env::set_var("TERM", v),
4404 None => std::env::remove_var("TERM"),
4405 }
4406 }
4407 }
4408
4409 #[test]
4410 fn non_vscode_term_program_does_not_force_low_motion() {
4411 let _g = term_program_test_guard();
4412 let prev = std::env::var_os("TERM_PROGRAM");
4413 let prev_term = std::env::var_os("TERM");
4414 let prev_ssh_client = std::env::var_os("SSH_CLIENT");
4415 let prev_ssh_tty = std::env::var_os("SSH_TTY");
4416 let prev_tilix_id = std::env::var_os("TILIX_ID");
4417 let prev_terminator_uuid = std::env::var_os("TERMINATOR_UUID");
4418 let prev_tmux = std::env::var_os("TMUX");
4419 let prev_sty = std::env::var_os("STY");
4420 // SAFETY: serialised by the guard. Clear SSH_* so a real
4421 // SSH session running the test suite doesn't make this
4422 // assertion trivially fail — the SSH path is exercised
4423 // separately by `ssh_session_forces_low_motion_on`.
4424 unsafe {
4425 std::env::remove_var("SSH_CLIENT");
4426 std::env::remove_var("SSH_TTY");
4427 std::env::remove_var("TERM");
4428 std::env::remove_var("TILIX_ID");
4429 std::env::remove_var("TERMINATOR_UUID");
4430 std::env::remove_var("TMUX");
4431 std::env::remove_var("STY");
4432 }
4433 for program in ["iTerm.app", "Apple_Terminal", "WezTerm", "xterm-256color"] {
4434 // SAFETY: serialised by the guard.
4435 unsafe {
4436 std::env::set_var("TERM_PROGRAM", program);
4437 }
4438 let mut s = animated_settings();
4439 s.apply_env_overrides();
4440 assert!(
4441 !s.low_motion,
4442 "TERM_PROGRAM={program:?} should not force low_motion"
4443 );
4444 }
4445 // SAFETY: cleanup under the guard.
4446 unsafe {
4447 match prev {
4448 Some(v) => std::env::set_var("TERM_PROGRAM", v),
4449 None => std::env::remove_var("TERM_PROGRAM"),
4450 }
4451 match prev_term {
4452 Some(v) => std::env::set_var("TERM", v),
4453 None => std::env::remove_var("TERM"),
4454 }
4455 if let Some(v) = prev_ssh_client {
4456 std::env::set_var("SSH_CLIENT", v);
4457 }
4458 if let Some(v) = prev_ssh_tty {
4459 std::env::set_var("SSH_TTY", v);
4460 }
4461 if let Some(v) = prev_tilix_id {
4462 std::env::set_var("TILIX_ID", v);
4463 }
4464 if let Some(v) = prev_terminator_uuid {
4465 std::env::set_var("TERMINATOR_UUID", v);
4466 }
4467 if let Some(v) = prev_tmux {
4468 std::env::set_var("TMUX", v);
4469 }
4470 if let Some(v) = prev_sty {
4471 std::env::set_var("STY", v);
4472 }
4473 }
4474 }
4475
4476 #[test]
4477 fn tilix_and_terminator_cap_redraws_without_disabling_motion() {
4478 let _g = term_program_test_guard();
4479 // Neutralize the SSH markers: production intentionally caps motion
4480 // over SSH, and the suite routinely runs inside one.
4481 let _ssh_client = crate::test_support::EnvVarGuard::remove("SSH_CLIENT");
4482 let _ssh_connection = crate::test_support::EnvVarGuard::remove("SSH_CONNECTION");
4483 let _ssh_tty = crate::test_support::EnvVarGuard::remove("SSH_TTY");
4484 let prev_term_program = std::env::var_os("TERM_PROGRAM");
4485 let prev_tilix_id = std::env::var_os("TILIX_ID");
4486 let prev_terminator_uuid = std::env::var_os("TERMINATOR_UUID");
4487 let prev_wt_session = std::env::var_os("WT_SESSION");
4488
4489 for (var, val) in [
4490 ("TILIX_ID", "d5b5b5d6-tilix-session"),
4491 ("TERMINATOR_UUID", "urn:uuid:terminator-session"),
4492 ] {
4493 // SAFETY: serialised by the guard.
4494 unsafe {
4495 std::env::remove_var("TERM_PROGRAM");
4496 std::env::remove_var("TILIX_ID");
4497 std::env::remove_var("TERMINATOR_UUID");
4498 std::env::set_var(var, val);
4499 // A native Windows test process without any modern-terminal
4500 // marker is intentionally treated as legacy ConHost. This
4501 // test isolates the VTE signal instead, so keep that separate
4502 // platform heuristic from changing its motion assertions.
4503 #[cfg(windows)]
4504 std::env::set_var("WT_SESSION", "codewhale-test");
4505 }
4506 let mut settings = animated_settings();
4507 assert!(!settings.low_motion, "default is animated");
4508 settings.apply_env_overrides();
4509 assert!(
4510 settings.constrained_frame_rate,
4511 "{var} must cap redraws to prevent VTE flicker (#1470)"
4512 );
4513 assert!(
4514 !settings.low_motion,
4515 "{var} must not change motion semantics"
4516 );
4517 assert!(
4518 settings.fancy_animations,
4519 "{var} must not disable the ocean treatment"
4520 );
4521 }
4522
4523 // SAFETY: cleanup under the guard.
4524 unsafe {
4525 match prev_term_program {
4526 Some(v) => std::env::set_var("TERM_PROGRAM", v),
4527 None => std::env::remove_var("TERM_PROGRAM"),
4528 }
4529 match prev_tilix_id {
4530 Some(v) => std::env::set_var("TILIX_ID", v),
4531 None => std::env::remove_var("TILIX_ID"),
4532 }
4533 match prev_terminator_uuid {
4534 Some(v) => std::env::set_var("TERMINATOR_UUID", v),
4535 None => std::env::remove_var("TERMINATOR_UUID"),
4536 }
4537 match prev_wt_session {
4538 Some(v) => std::env::set_var("WT_SESSION", v),
4539 None => std::env::remove_var("WT_SESSION"),
4540 }
4541 }
4542 }
4543
4544 #[test]
4545 fn termius_term_program_forces_low_motion_on() {
4546 let _g = term_program_test_guard();
4547 let prev = std::env::var_os("TERM_PROGRAM");
4548 // SAFETY: serialised by the guard.
4549 unsafe {
4550 std::env::set_var("TERM_PROGRAM", "Termius");
4551 }
4552 let mut settings = animated_settings();
4553 assert!(!settings.low_motion, "default is animated");
4554 settings.apply_env_overrides();
4555 assert!(
4556 settings.low_motion,
4557 "TERM_PROGRAM=Termius must enable low_motion to prevent flickering (#1433)"
4558 );
4559 assert!(
4560 !settings.fancy_animations,
4561 "TERM_PROGRAM=Termius must disable fancy_animations"
4562 );
4563 // SAFETY: cleanup under the guard.
4564 unsafe {
4565 match prev {
4566 Some(v) => std::env::set_var("TERM_PROGRAM", v),
4567 None => std::env::remove_var("TERM_PROGRAM"),
4568 }
4569 }
4570 }
4571
4572 #[test]
4573 fn legacy_windows_console_host_detects_unmarked_shell() {
4574 assert!(legacy_windows_console_host_env([
4575 None, None, None, None, None, None, None, None
4576 ]));
4577 }
4578
4579 #[test]
4580 fn legacy_windows_console_host_excludes_modern_terminal_markers() {
4581 use std::ffi::OsStr;
4582
4583 let marker = Some(OsStr::new("1"));
4584 assert!(!legacy_windows_console_host_env([
4585 marker, None, None, None, None, None, None, None
4586 ]));
4587 assert!(!legacy_windows_console_host_env([
4588 None, marker, None, None, None, None, None, None
4589 ]));
4590 assert!(!legacy_windows_console_host_env([
4591 None, None, marker, None, None, None, None, None
4592 ]));
4593 assert!(!legacy_windows_console_host_env([
4594 None, None, None, marker, None, None, None, None
4595 ]));
4596 assert!(!legacy_windows_console_host_env([
4597 None, None, None, None, marker, None, None, None
4598 ]));
4599 assert!(!legacy_windows_console_host_env([
4600 None, None, None, None, None, marker, None, None
4601 ]));
4602 assert!(!legacy_windows_console_host_env([
4603 None, None, None, None, None, None, marker, None
4604 ]));
4605 assert!(!legacy_windows_console_host_env([
4606 None, None, None, None, None, None, None, marker
4607 ]));
4608 }
4609
4610 #[cfg(windows)]
4611 #[test]
4612 fn unmarked_windows_console_forces_calm_rendering() {
4613 let _g = term_program_test_guard();
4614 let vars = [
4615 "WT_SESSION",
4616 "ConEmuPID",
4617 "TERM_PROGRAM",
4618 "WEZTERM_EXECUTABLE",
4619 "WEZTERM_PANE",
4620 "ALACRITTY_WINDOW_ID",
4621 "ANSICON",
4622 "TERM",
4623 "SSH_CLIENT",
4624 "SSH_TTY",
4625 "NO_ANIMATIONS",
4626 "PTYXIS_VERSION",
4627 ];
4628 let prev: Vec<_> = vars
4629 .iter()
4630 .map(|name| (*name, std::env::var_os(name)))
4631 .collect();
4632
4633 // SAFETY: serialised by the guard.
4634 unsafe {
4635 for name in vars {
4636 std::env::remove_var(name);
4637 }
4638 }
4639
4640 let mut settings = animated_settings();
4641 assert!(!settings.low_motion, "default is animated");
4642 assert!(settings.fancy_animations, "default shows the water strip");
4643 assert_eq!(settings.synchronized_output, "auto");
4644 settings.apply_env_overrides();
4645 assert!(settings.low_motion);
4646 assert!(!settings.fancy_animations);
4647 assert!(
4648 settings.bracketed_paste,
4649 "env-only conhost fallback must not persistently mutate bracketed_paste (#1102)"
4650 );
4651 assert!(
4652 !settings.effective_bracketed_paste(),
4653 "legacy Windows console hosts do not support crossterm bracketed paste (#1102)"
4654 );
4655 assert_eq!(settings.synchronized_output, "off");
4656
4657 // SAFETY: cleanup under the guard.
4658 unsafe {
4659 for (name, value) in prev {
4660 match value {
4661 Some(value) => std::env::set_var(name, value),
4662 None => std::env::remove_var(name),
4663 }
4664 }
4665 }
4666 }
4667
4668 #[test]
4669 fn ssh_session_forces_low_motion_on() {
4670 let _g = term_program_test_guard();
4671 let prev_client = std::env::var_os("SSH_CLIENT");
4672 let prev_tty = std::env::var_os("SSH_TTY");
4673 let prev_term_program = std::env::var_os("TERM_PROGRAM");
4674 for (var, val) in [
4675 ("SSH_CLIENT", "192.168.1.100 50000 22"),
4676 ("SSH_TTY", "/dev/pts/0"),
4677 ] {
4678 // SAFETY: serialised by the guard.
4679 unsafe {
4680 std::env::remove_var("SSH_CLIENT");
4681 std::env::remove_var("SSH_TTY");
4682 // Clear TERM_PROGRAM so the test isolates the SSH signal
4683 // — otherwise a leaked `TERM_PROGRAM=vscode` from a
4684 // concurrent test would already have forced low_motion
4685 // and the SSH-only assertion below would be a tautology.
4686 std::env::remove_var("TERM_PROGRAM");
4687 std::env::set_var(var, val);
4688 }
4689 let mut s = Settings::default();
4690 s.apply_env_overrides();
4691 assert!(
4692 s.low_motion,
4693 "{var}={val:?} must enable low_motion to prevent flickering in SSH sessions (#1433)"
4694 );
4695 assert!(
4696 !s.fancy_animations,
4697 "{var}={val:?} must disable fancy_animations in SSH sessions (#1433)"
4698 );
4699 }
4700 // SAFETY: cleanup under the guard.
4701 unsafe {
4702 std::env::remove_var("SSH_CLIENT");
4703 std::env::remove_var("SSH_TTY");
4704 if let Some(v) = prev_client {
4705 std::env::set_var("SSH_CLIENT", v);
4706 }
4707 if let Some(v) = prev_tty {
4708 std::env::set_var("SSH_TTY", v);
4709 }
4710 match prev_term_program {
4711 Some(v) => std::env::set_var("TERM_PROGRAM", v),
4712 None => std::env::remove_var("TERM_PROGRAM"),
4713 }
4714 }
4715 }
4716
4717 #[test]
4718 fn terminal_multiplexer_caps_redraws_without_disabling_motion() {
4719 let _g = term_program_test_guard();
4720 let vars = [
4721 "TMUX",
4722 "STY",
4723 "TERM_PROGRAM",
4724 "SSH_CLIENT",
4725 "SSH_TTY",
4726 "TILIX_ID",
4727 "TERMINATOR_UUID",
4728 "NO_ANIMATIONS",
4729 "WT_SESSION",
4730 ];
4731 let prev: Vec<_> = vars
4732 .iter()
4733 .map(|name| (*name, std::env::var_os(name)))
4734 .collect();
4735
4736 for (var, val) in [
4737 ("TMUX", "/tmp/tmux-501/default,1234,0"),
4738 ("STY", "1234.pts-0.host"),
4739 ] {
4740 // SAFETY: serialised by the guard.
4741 unsafe {
4742 for name in vars {
4743 std::env::remove_var(name);
4744 }
4745 std::env::set_var(var, val);
4746 #[cfg(windows)]
4747 std::env::set_var("WT_SESSION", "codewhale-test");
4748 }
4749 let mut settings = animated_settings();
4750 assert!(!settings.low_motion, "default is animated");
4751 assert!(settings.fancy_animations, "default shows the water strip");
4752 settings.apply_env_overrides();
4753 assert!(!settings.low_motion, "{var} must preserve authored motion");
4754 assert!(
4755 settings.fancy_animations,
4756 "{var} must preserve Ocean motion"
4757 );
4758 assert!(
4759 settings.constrained_frame_rate,
4760 "{var}={val:?} must cap redraws under terminal multiplexers"
4761 );
4762 }
4763
4764 // SAFETY: cleanup under the guard.
4765 unsafe {
4766 for (name, value) in prev {
4767 match value {
4768 Some(value) => std::env::set_var(name, value),
4769 None => std::env::remove_var(name),
4770 }
4771 }
4772 }
4773 }
4774
4775 // ────────────────────────────────────────────────────────────────────────
4776 // synchronized_output / Ptyxis flicker detection
4777 // ────────────────────────────────────────────────────────────────────────
4778
4779 #[test]
4780 fn synchronized_output_defaults_to_auto_and_resolves_to_enabled() {
4781 let s = Settings::default();
4782 assert_eq!(s.synchronized_output, "auto");
4783 assert!(
4784 s.synchronized_output_enabled(),
4785 "auto must keep DEC 2026 on so terminals that support it stay tear-free"
4786 );
4787 }
4788
4789 #[test]
4790 fn synchronized_output_off_disables_dec_2026() {
4791 let s = Settings {
4792 synchronized_output: "off".to_string(),
4793 ..Settings::default()
4794 };
4795 assert!(!s.synchronized_output_enabled());
4796 }
4797
4798 #[test]
4799 fn synchronized_output_on_keeps_dec_2026_enabled() {
4800 let s = Settings {
4801 synchronized_output: "on".to_string(),
4802 ..Settings::default()
4803 };
4804 assert!(s.synchronized_output_enabled());
4805 }
4806
4807 #[test]
4808 fn synchronized_output_set_command_accepts_aliases() {
4809 let mut s = Settings::default();
4810 for value in ["auto", "AUTO", "default"] {
4811 s.set("synchronized_output", value).expect("valid");
4812 assert_eq!(s.synchronized_output, "auto");
4813 }
4814 for value in ["on", "true", "yes", "1", "ENABLED"] {
4815 s.set("sync_output", value).expect("valid");
4816 assert_eq!(s.synchronized_output, "on");
4817 }
4818 for value in ["off", "false", "no", "0", "DISABLED"] {
4819 s.set("sync", value).expect("valid");
4820 assert_eq!(s.synchronized_output, "off");
4821 }
4822 let err = s
4823 .set("synchronized_output", "maybe")
4824 .expect_err("unknown value rejected");
4825 assert!(
4826 err.to_string().contains("synchronized_output"),
4827 "error names the offending key: {err}"
4828 );
4829 }
4830
4831 #[test]
4832 fn composer_multiline_mode_defaults_off_and_accepts_boolean_aliases() {
4833 let mut settings = Settings::default();
4834 assert!(!settings.composer_multiline_mode);
4835
4836 settings.set("multiline", "on").expect("enable multiline");
4837 assert!(settings.composer_multiline_mode);
4838
4839 settings
4840 .set("composer_multiline_mode", "false")
4841 .expect("disable multiline");
4842 assert!(!settings.composer_multiline_mode);
4843 }
4844
4845 #[test]
4846 fn ptyxis_term_program_flips_synchronized_output_off() {
4847 let _g = term_program_test_guard();
4848 let prev = std::env::var_os("TERM_PROGRAM");
4849 let prev_ptyxis = std::env::var_os("PTYXIS_VERSION");
4850 // SAFETY: serialised by the guard.
4851 unsafe {
4852 std::env::set_var("TERM_PROGRAM", "Ptyxis");
4853 std::env::remove_var("PTYXIS_VERSION");
4854 }
4855 let mut s = Settings::default();
4856 assert_eq!(s.synchronized_output, "auto");
4857 s.apply_env_overrides();
4858 assert_eq!(
4859 s.synchronized_output, "off",
4860 "Ptyxis 50.x mishandles DEC 2026 — auto must flip to off so VTE 0.84 stops flickering"
4861 );
4862 assert!(
4863 !s.synchronized_output_enabled(),
4864 "resolved boolean must agree with stored string"
4865 );
4866 // SAFETY: cleanup under the guard.
4867 unsafe {
4868 match prev {
4869 Some(v) => std::env::set_var("TERM_PROGRAM", v),
4870 None => std::env::remove_var("TERM_PROGRAM"),
4871 }
4872 match prev_ptyxis {
4873 Some(v) => std::env::set_var("PTYXIS_VERSION", v),
4874 None => std::env::remove_var("PTYXIS_VERSION"),
4875 }
4876 }
4877 }
4878
4879 #[test]
4880 fn tabby_uses_calm_rendering_for_stable_ime_cursor() {
4881 let _g = term_program_test_guard();
4882 let prev = std::env::var_os("TERM_PROGRAM");
4883 // SAFETY: serialised by the guard.
4884 unsafe {
4885 std::env::set_var("TERM_PROGRAM", "Tabby");
4886 }
4887 let mut settings = animated_settings();
4888 settings.apply_env_overrides();
4889 assert!(settings.low_motion);
4890 assert!(!settings.fancy_animations);
4891 assert!(settings.constrained_frame_rate);
4892 assert_eq!(settings.synchronized_output, "off");
4893 // SAFETY: cleanup under the guard.
4894 unsafe {
4895 match prev {
4896 Some(v) => std::env::set_var("TERM_PROGRAM", v),
4897 None => std::env::remove_var("TERM_PROGRAM"),
4898 }
4899 }
4900 }
4901
4902 #[test]
4903 fn ptyxis_version_env_alone_flips_synchronized_output_off() {
4904 let _g = term_program_test_guard();
4905 let prev = std::env::var_os("TERM_PROGRAM");
4906 let prev_ptyxis = std::env::var_os("PTYXIS_VERSION");
4907 // SAFETY: serialised by the guard.
4908 unsafe {
4909 std::env::remove_var("TERM_PROGRAM");
4910 std::env::set_var("PTYXIS_VERSION", "50.1");
4911 }
4912 let mut s = Settings::default();
4913 s.apply_env_overrides();
4914 assert_eq!(
4915 s.synchronized_output, "off",
4916 "PTYXIS_VERSION alone is sufficient — Ptyxis sets this even when TERM_PROGRAM isn't propagated"
4917 );
4918 // SAFETY: cleanup under the guard.
4919 unsafe {
4920 match prev {
4921 Some(v) => std::env::set_var("TERM_PROGRAM", v),
4922 None => std::env::remove_var("TERM_PROGRAM"),
4923 }
4924 match prev_ptyxis {
4925 Some(v) => std::env::set_var("PTYXIS_VERSION", v),
4926 None => std::env::remove_var("PTYXIS_VERSION"),
4927 }
4928 }
4929 }
4930
4931 #[test]
4932 fn ptyxis_does_not_override_user_explicit_on() {
4933 // Users who set `synchronized_output = "on"` (e.g. to confirm a
4934 // Ptyxis upgrade fixed it) must keep DEC 2026 even on Ptyxis.
4935 let _g = term_program_test_guard();
4936 let prev = std::env::var_os("TERM_PROGRAM");
4937 // SAFETY: serialised by the guard.
4938 unsafe {
4939 std::env::set_var("TERM_PROGRAM", "ptyxis");
4940 }
4941 let mut s = Settings {
4942 synchronized_output: "on".to_string(),
4943 ..Settings::default()
4944 };
4945 s.apply_env_overrides();
4946 assert_eq!(
4947 s.synchronized_output, "on",
4948 "explicit user override must beat the Ptyxis env heuristic"
4949 );
4950 // SAFETY: cleanup under the guard.
4951 unsafe {
4952 match prev {
4953 Some(v) => std::env::set_var("TERM_PROGRAM", v),
4954 None => std::env::remove_var("TERM_PROGRAM"),
4955 }
4956 }
4957 }
4958
4959 #[test]
4960 fn ptyxis_does_not_override_user_explicit_off() {
4961 // A user with `synchronized_output = "off"` on a non-Ptyxis
4962 // terminal stays off after env detection (no-op flip).
4963 let _g = term_program_test_guard();
4964 let prev = std::env::var_os("TERM_PROGRAM");
4965 // SAFETY: serialised by the guard.
4966 unsafe {
4967 std::env::set_var("TERM_PROGRAM", "xterm-256color");
4968 }
4969 let mut s = Settings {
4970 synchronized_output: "off".to_string(),
4971 ..Settings::default()
4972 };
4973 s.apply_env_overrides();
4974 assert_eq!(s.synchronized_output, "off");
4975 // SAFETY: cleanup under the guard.
4976 unsafe {
4977 match prev {
4978 Some(v) => std::env::set_var("TERM_PROGRAM", v),
4979 None => std::env::remove_var("TERM_PROGRAM"),
4980 }
4981 }
4982 }
4983
4984 #[test]
4985 fn non_ptyxis_term_programs_keep_synchronized_output_auto() {
4986 let _g = term_program_test_guard();
4987 let prev = std::env::var_os("TERM_PROGRAM");
4988 let prev_ptyxis = std::env::var_os("PTYXIS_VERSION");
4989 // SAFETY: clean slate so non-Ptyxis programs don't see a leaked
4990 // PTYXIS_VERSION from another test.
4991 unsafe {
4992 std::env::remove_var("PTYXIS_VERSION");
4993 }
4994 for program in [
4995 "iTerm.app",
4996 "Apple_Terminal",
4997 "WezTerm",
4998 "xterm-256color",
4999 "gnome-terminal-server",
5000 // The Ghostty / VS Code paths keep DEC 2026 enabled; both handle
5001 // synchronized output cleanly even though their motion policies
5002 // differ.
5003 "ghostty",
5004 "vscode",
5005 ] {
5006 // SAFETY: serialised by the guard.
5007 unsafe {
5008 std::env::set_var("TERM_PROGRAM", program);
5009 }
5010 let mut s = Settings::default();
5011 s.apply_env_overrides();
5012 assert_eq!(
5013 s.synchronized_output, "auto",
5014 "TERM_PROGRAM={program:?} must not opt out of DEC 2026"
5015 );
5016 assert!(
5017 s.synchronized_output_enabled(),
5018 "resolved boolean for {program:?} must stay enabled"
5019 );
5020 }
5021 // SAFETY: cleanup under the guard.
5022 unsafe {
5023 match prev {
5024 Some(v) => std::env::set_var("TERM_PROGRAM", v),
5025 None => std::env::remove_var("TERM_PROGRAM"),
5026 }
5027 match prev_ptyxis {
5028 Some(v) => std::env::set_var("PTYXIS_VERSION", v),
5029 None => std::env::remove_var("PTYXIS_VERSION"),
5030 }
5031 }
5032 }
5033
5034 // ────────────────────────────────────────────────────────────────────────
5035 // Settings store tests
5036 // ────────────────────────────────────────────────────────────────────────
5037
5038 /// Serialise tests that mutate `DEEPSEEK_CONFIG_PATH` through this guard
5039 /// so the parallel test runner doesn't observe interleaved env values.
5040 fn config_path_test_guard() -> crate::test_support::TestEnvLock {
5041 crate::test_support::lock_test_env()
5042 }
5043
5044 /// The shared guard, under this module's historical name.
5045 ///
5046 /// It was a byte-for-byte copy of `EnvVarGuard` until #5359 gave the shared
5047 /// one a second job: recording which variables a test actually redirected,
5048 /// so state-path resolution can tell a sealed environment from a test that
5049 /// holds the lock for unrelated reasons. A private copy silently opts every
5050 /// caller here out of that record.
5051 use crate::test_support::EnvVarGuard as EnvVarRestore;
5052
5053 #[test]
5054 fn startup_mode_writes_accept_act_plan_operate() {
5055 let mut settings = Settings::default();
5056
5057 settings.set("default_mode", "plan").expect("plan mode");
5058 assert_eq!(settings.default_mode, "plan");
5059 settings
5060 .set("default_mode", "normal")
5061 .expect("legacy normal alias remains harmless");
5062 assert_eq!(settings.default_mode, "agent");
5063 settings
5064 .set("default_mode", "operate")
5065 .expect("operate is a valid startup mode");
5066 assert_eq!(settings.default_mode, "operate");
5067 settings
5068 .set("default_mode", "act")
5069 .expect("act alias maps to agent wire value");
5070 assert_eq!(settings.default_mode, "agent");
5071
5072 let err = settings
5073 .set("default_mode", "yolo")
5074 .expect_err("yolo remains a permission migration alias, not a mode write");
5075 assert!(
5076 err.to_string().contains("act (agent), plan, or operate"),
5077 "{err}"
5078 );
5079 }
5080
5081 #[test]
5082 fn legacy_startup_modes_migrate_without_losing_permission_intent() {
5083 let _g = config_path_test_guard();
5084 let tmp = tempfile::tempdir().expect("tempdir");
5085 let codewhale_home = tmp.path().join(".codewhale");
5086 std::fs::create_dir_all(&codewhale_home).expect("codewhale home");
5087 std::fs::write(
5088 codewhale_home.join("settings.toml"),
5089 "default_mode = \"yolo\"\n",
5090 )
5091 .expect("legacy settings");
5092 let _config_override = EnvVarRestore::remove("DEEPSEEK_CONFIG_PATH");
5093 let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", &codewhale_home);
5094 let _home = EnvVarRestore::set("HOME", tmp.path());
5095
5096 let loaded = Settings::load_persisted().expect("load legacy settings");
5097
5098 assert_eq!(loaded.default_mode, "agent");
5099 assert_eq!(loaded.permission_posture.as_deref(), Some("full-access"));
5100
5101 std::fs::write(
5102 codewhale_home.join("settings.toml"),
5103 "default_mode = \"operate\"\n",
5104 )
5105 .expect("operate startup settings");
5106 let loaded = Settings::load_persisted().expect("load operate settings");
5107 assert_eq!(loaded.default_mode, "operate");
5108 assert_eq!(loaded.permission_posture, None);
5109 }
5110
5111 #[test]
5112 fn settings_path_defaults_to_codewhale_home_for_new_writes() {
5113 let _g = config_path_test_guard();
5114 let tmp = tempfile::tempdir().expect("tempdir");
5115 let _config_override = EnvVarRestore::remove("DEEPSEEK_CONFIG_PATH");
5116 let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", tmp.path().join(".codewhale"));
5117 let _home = EnvVarRestore::set("HOME", tmp.path());
5118
5119 let got = Settings::path().expect("settings path");
5120
5121 assert_eq!(got, tmp.path().join(".codewhale").join("settings.toml"));
5122 }
5123
5124 #[test]
5125 fn settings_path_prefers_codewhale_home_even_when_legacy_exists() {
5126 let _g = config_path_test_guard();
5127 let tmp = tempfile::tempdir().expect("tempdir");
5128 let legacy_dir = tmp.path().join(".deepseek");
5129 std::fs::create_dir_all(&legacy_dir).expect("legacy dir");
5130 std::fs::write(legacy_dir.join("settings.toml"), "low_motion = true\n")
5131 .expect("legacy settings");
5132 let _config_override = EnvVarRestore::remove("DEEPSEEK_CONFIG_PATH");
5133 let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", tmp.path().join(".codewhale"));
5134 let _home = EnvVarRestore::set("HOME", tmp.path());
5135
5136 let got = Settings::path().expect("settings path");
5137
5138 assert_eq!(got, tmp.path().join(".codewhale").join("settings.toml"));
5139 }
5140
5141 #[test]
5142 fn settings_load_migrates_legacy_deepseek_home_into_codewhale_home_without_explicit_home() {
5143 let _g = config_path_test_guard();
5144 let tmp = tempfile::tempdir().expect("tempdir");
5145 let primary = tmp.path().join(".codewhale").join("settings.toml");
5146 let legacy_dir = tmp.path().join(".deepseek");
5147 let legacy_home = legacy_dir.join("settings.toml");
5148 std::fs::create_dir_all(&legacy_dir).expect("legacy dir");
5149 std::fs::write(&legacy_home, "low_motion = true\n").expect("legacy settings");
5150 let _config_override = EnvVarRestore::remove("DEEPSEEK_CONFIG_PATH");
5151 let _codewhale_home = EnvVarRestore::remove("CODEWHALE_HOME");
5152 let _home = EnvVarRestore::set("HOME", tmp.path());
5153
5154 let loaded = Settings::load_persisted().expect("load persisted settings");
5155
5156 assert!(loaded.low_motion, "legacy settings should still be read");
5157 assert!(
5158 primary.exists(),
5159 "settings load should migrate to primary path"
5160 );
5161 let display = loaded.display(codewhale_localization::Locale::En);
5162 assert!(
5163 display.contains(&format!("Config file: {}", primary.display())),
5164 "settings display should surface the canonical codewhale path:\n{display}"
5165 );
5166 }
5167
5168 #[test]
5169 fn settings_load_read_only_reads_legacy_home_without_creating_primary() {
5170 let _g = config_path_test_guard();
5171 let tmp = tempfile::tempdir().expect("tempdir");
5172 let primary = tmp.path().join(".codewhale").join("settings.toml");
5173 let legacy = tmp.path().join(".deepseek").join("settings.toml");
5174 let legacy_bytes =
5175 b"default_mode = \"plan\"\nlow_motion = false\nfancy_animations = true\n";
5176 std::fs::create_dir_all(legacy.parent().expect("legacy parent")).expect("legacy directory");
5177 std::fs::write(&legacy, legacy_bytes).expect("legacy settings");
5178 let _config_override = EnvVarRestore::remove("DEEPSEEK_CONFIG_PATH");
5179 let _codewhale_home = EnvVarRestore::remove("CODEWHALE_HOME");
5180 let _home = EnvVarRestore::set("HOME", tmp.path());
5181 let _no_animations = EnvVarRestore::set("NO_ANIMATIONS", "1");
5182
5183 let loaded = Settings::load_read_only().expect("read-only settings load");
5184
5185 assert_eq!(loaded.default_mode, "plan");
5186 assert!(loaded.low_motion, "environment overlays still apply");
5187 assert!(
5188 !loaded.fancy_animations,
5189 "environment overlays still apply to parsed legacy settings"
5190 );
5191 assert!(
5192 !primary.exists(),
5193 "a diagnostic settings read must not create the primary settings path"
5194 );
5195 assert_eq!(
5196 std::fs::read(&legacy).expect("legacy settings after read"),
5197 legacy_bytes,
5198 "a diagnostic settings read must not rewrite the legacy settings file"
5199 );
5200 }
5201
5202 #[test]
5203 fn legacy_route_preferences_ignore_project_settings_and_runtime_overlays() {
5204 let _g = config_path_test_guard();
5205 let tmp = tempfile::tempdir().expect("tempdir");
5206 let global = tmp.path().join("global");
5207 let project = tmp.path().join("project");
5208 std::fs::create_dir_all(&global).expect("global directory");
5209 std::fs::create_dir_all(&project).expect("project directory");
5210 let global_bytes = b"default_provider = \"zai\"\nlow_motion = false\n[provider_models]\nzai = \"GLM-5.3\"\n";
5211 let project_bytes = b"default_provider = \"openai\"\nlow_motion = false\n[provider_models]\nopenai = \"project-model\"\n";
5212 let global_settings = global.join(SETTINGS_FILE_NAME);
5213 let project_settings = project.join(SETTINGS_FILE_NAME);
5214 std::fs::write(&global_settings, global_bytes).expect("global settings");
5215 std::fs::write(&project_settings, project_bytes).expect("project settings");
5216 let _global_home = EnvVarRestore::set("CODEWHALE_HOME", &global);
5217 let _config_override =
5218 EnvVarRestore::set("CODEWHALE_CONFIG_PATH", project.join("config.toml"));
5219 let _legacy_override =
5220 EnvVarRestore::set("DEEPSEEK_CONFIG_PATH", project.join("config.toml"));
5221 let _no_animations = EnvVarRestore::set("NO_ANIMATIONS", "1");
5222
5223 let legacy =
5224 Settings::load_legacy_route_preferences_read_only().expect("global legacy preferences");
5225 assert_eq!(legacy.default_provider.as_deref(), Some("zai"));
5226 assert_eq!(
5227 legacy
5228 .provider_models
5229 .as_ref()
5230 .and_then(|models| models.get("zai"))
5231 .map(String::as_str),
5232 Some("GLM-5.3")
5233 );
5234 assert!(
5235 !legacy.low_motion,
5236 "migration must read the persisted value"
5237 );
5238 let ordinary = Settings::load_read_only().expect("ordinary project settings");
5239 assert_eq!(ordinary.default_provider.as_deref(), Some("openai"));
5240 assert!(
5241 ordinary.low_motion,
5242 "ordinary runtime overlays are unchanged"
5243 );
5244 assert_eq!(
5245 std::fs::read(&global_settings).expect("unchanged global settings"),
5246 global_bytes
5247 );
5248 assert_eq!(
5249 std::fs::read(&project_settings).expect("unchanged project settings"),
5250 project_bytes
5251 );
5252
5253 std::fs::remove_file(&global_settings).expect("remove fixture global settings");
5254 let missing = Settings::load_legacy_route_preferences_read_only()
5255 .expect("missing global preferences");
5256 assert_eq!(missing.default_provider, None);
5257 assert!(missing.provider_models.is_none());
5258 assert!(
5259 !global_settings.exists(),
5260 "migration reads must not create settings"
5261 );
5262 assert!(!global.join("config.toml").exists());
5263 assert!(!project.join("config.toml").exists());
5264 }
5265
5266 #[test]
5267 fn project_path_guard_does_not_authorize_global_legacy_settings_reads() {
5268 let _g = config_path_test_guard();
5269 let tmp = tempfile::tempdir().expect("tempdir");
5270 let _config_override =
5271 EnvVarRestore::set("CODEWHALE_CONFIG_PATH", tmp.path().join("config.toml"));
5272 let _global_home = EnvVarRestore::remove("CODEWHALE_HOME");
5273 let _home = EnvVarRestore::remove("HOME");
5274 let _userprofile = EnvVarRestore::remove("USERPROFILE");
5275
5276 assert_eq!(
5277 settings_path_candidates_for_scope(false),
5278 (
5279 Some(crate::test_support::unsealed_test_state_root().join(SETTINGS_FILE_NAME)),
5280 None,
5281 None,
5282 ),
5283 "a project-only test must stay isolated when reading global preferences"
5284 );
5285 }
5286
5287 #[test]
5288 fn settings_load_migrates_platform_legacy_fallback_into_codewhale_home_without_explicit_home() {
5289 let _g = config_path_test_guard();
5290 let tmp = tempfile::tempdir().expect("tempdir");
5291 let primary = tmp.path().join(".codewhale").join("settings.toml");
5292 let _config_override = EnvVarRestore::remove("DEEPSEEK_CONFIG_PATH");
5293 let _codewhale_home =
5294 EnvVarRestore::set("CODEWHALE_HOME", primary.parent().expect("primary parent"));
5295 let legacy_config_dir = tmp
5296 .path()
5297 .join("platform-config")
5298 .join("deepseek")
5299 .join("settings.toml");
5300 std::fs::create_dir_all(legacy_config_dir.parent().expect("parent"))
5301 .expect("legacy config dir");
5302 std::fs::write(&legacy_config_dir, "low_motion = true\n").expect("legacy settings");
5303
5304 // Exercise the same load and migration path with explicit candidates.
5305 // `dirs::config_dir()` uses the Win32 known-folder API on Windows, so
5306 // APPDATA/XDG environment overrides cannot isolate that process-global
5307 // location in a parallel test runner.
5308 let loaded = Settings::load_persisted_from_candidates(
5309 Some(primary.clone()),
5310 None,
5311 Some(legacy_config_dir),
5312 )
5313 .expect("load persisted settings");
5314
5315 assert!(loaded.low_motion, "legacy settings should still be read");
5316 assert!(
5317 primary.exists(),
5318 "legacy fallback should be copied into primary"
5319 );
5320 let display = loaded.display(codewhale_localization::Locale::En);
5321 assert!(
5322 display.contains(&format!("Config file: {}", primary.display())),
5323 "settings display should surface the canonical codewhale path:\n{display}"
5324 );
5325 }
5326
5327 #[test]
5328 fn settings_load_ignores_legacy_files_when_codewhale_home_is_explicit() {
5329 let _g = config_path_test_guard();
5330 let tmp = tempfile::tempdir().expect("tempdir");
5331 let explicit_home = tmp.path().join("isolated-codewhale");
5332 let legacy_dir = tmp.path().join(".deepseek");
5333 std::fs::create_dir_all(&legacy_dir).expect("legacy dir");
5334 std::fs::write(
5335 legacy_dir.join("settings.toml"),
5336 "theme = \"dracula\"\ncomposer_density = \"spacious\"\nsidebar_width_percent = 42\n",
5337 )
5338 .expect("legacy settings");
5339 let _config_override = EnvVarRestore::remove("DEEPSEEK_CONFIG_PATH");
5340 let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", &explicit_home);
5341 let _home = EnvVarRestore::set("HOME", tmp.path());
5342
5343 let loaded = Settings::load().expect("load settings");
5344
5345 assert_eq!(
5346 loaded.theme, "underwater",
5347 "explicit CODEWHALE_HOME must not inherit ambient legacy settings"
5348 );
5349 assert_eq!(
5350 loaded.composer_density, "comfortable",
5351 "explicit CODEWHALE_HOME must not inherit ambient legacy settings"
5352 );
5353 assert_eq!(
5354 loaded.sidebar_width_percent, 28,
5355 "explicit CODEWHALE_HOME must not inherit ambient legacy settings"
5356 );
5357 assert!(
5358 !explicit_home.join("settings.toml").exists(),
5359 "ambient legacy settings must not be migrated into explicit CODEWHALE_HOME"
5360 );
5361 }
5362
5363 #[test]
5364 fn settings_load_migrates_legacy_saved_auto_sidebar_focus_to_rail() {
5365 let _g = config_path_test_guard();
5366 let tmp = tempfile::tempdir().expect("tempdir");
5367 let settings_path = tmp.path().join("settings.toml");
5368 std::fs::write(&settings_path, "sidebar_focus = \"auto\"\n").expect("settings");
5369 let _config_override =
5370 EnvVarRestore::set("DEEPSEEK_CONFIG_PATH", tmp.path().join("config.toml"));
5371
5372 let loaded = Settings::load().expect("load settings");
5373
5374 // A settings.toml that only names `sidebar_focus = "auto"` — the
5375 // shipped default — must not silently earn an always-on rail strip.
5376 assert_eq!(loaded.rail_panel, "tasks");
5377 assert_eq!(loaded.work_surface_placement, "bottom");
5378 }
5379
5380 #[test]
5381 fn settings_load_migrates_hidden_sidebar_to_rail_off() {
5382 let _g = config_path_test_guard();
5383 let tmp = tempfile::tempdir().expect("tempdir");
5384 let settings_path = tmp.path().join("settings.toml");
5385 std::fs::write(&settings_path, "sidebar_focus = \"hidden\"\n").expect("settings");
5386 let _config_override =
5387 EnvVarRestore::set("DEEPSEEK_CONFIG_PATH", tmp.path().join("config.toml"));
5388
5389 let loaded = Settings::load().expect("load settings");
5390
5391 assert_eq!(loaded.work_surface_placement, "off");
5392 }
5393
5394 #[test]
5395 fn hidden_legacy_sidebar_does_not_override_an_explicit_new_rail_placement() {
5396 let _g = config_path_test_guard();
5397 let tmp = tempfile::tempdir().expect("tempdir");
5398 let settings_path = tmp.path().join("settings.toml");
5399 std::fs::write(
5400 &settings_path,
5401 "sidebar_focus = \"hidden\"\nwork_surface_placement = \"left\"\n",
5402 )
5403 .expect("settings");
5404 let _config_override =
5405 EnvVarRestore::set("DEEPSEEK_CONFIG_PATH", tmp.path().join("config.toml"));
5406
5407 let loaded = Settings::load().expect("load settings");
5408
5409 assert_eq!(loaded.work_surface_placement, "left");
5410 }
5411
5412 /// The dead `tui.toml` store is folded into `settings.toml` on load: a
5413 /// value settings.toml does not own is adopted, a value it owns wins, the
5414 /// original bytes are moved aside, and unmappable keys are named.
5415 #[test]
5416 fn tui_toml_theme_is_folded_when_settings_toml_is_silent() {
5417 let _g = config_path_test_guard();
5418 let tmp = tempfile::tempdir().expect("tempdir");
5419 std::fs::write(
5420 tmp.path().join("settings.toml"),
5421 "cost_currency = \"usd\"\n",
5422 )
5423 .expect("settings");
5424 let prefs_path = tmp.path().join("tui.toml");
5425 std::fs::write(&prefs_path, "theme = \"light\"\n").expect("tui prefs");
5426 let _config_override =
5427 EnvVarRestore::set("DEEPSEEK_CONFIG_PATH", tmp.path().join("config.toml"));
5428
5429 let loaded = Settings::load().expect("load settings");
5430
5431 assert_eq!(loaded.theme, "light");
5432 let receipt = loaded.tui_prefs_migration().expect("receipt");
5433 assert_eq!(
5434 receipt.folded,
5435 vec![("theme".to_string(), "light".to_string())]
5436 );
5437 assert!(receipt.kept.is_empty());
5438 assert!(!prefs_path.exists(), "original must be moved aside");
5439 let backup = receipt.backup.as_ref().expect("backup path");
5440 assert_eq!(
5441 std::fs::read_to_string(backup).expect("backup readable"),
5442 "theme = \"light\"\n",
5443 "backup keeps the original bytes"
5444 );
5445 // The fold is only real once settings.toml owns it on disk.
5446 let persisted =
5447 std::fs::read_to_string(tmp.path().join("settings.toml")).expect("settings.toml");
5448 assert!(persisted.contains("light"), "not persisted: {persisted}");
5449 }
5450
5451 #[test]
5452 fn explicit_settings_theme_wins_over_tui_toml_and_the_receipt_says_so() {
5453 let _g = config_path_test_guard();
5454 let tmp = tempfile::tempdir().expect("tempdir");
5455 std::fs::write(tmp.path().join("settings.toml"), "theme = \"dark\"\n").expect("settings");
5456 std::fs::write(tmp.path().join("tui.toml"), "theme = \"light\"\n").expect("tui prefs");
5457 let _config_override =
5458 EnvVarRestore::set("DEEPSEEK_CONFIG_PATH", tmp.path().join("config.toml"));
5459
5460 let loaded = Settings::load().expect("load settings");
5461
5462 assert_eq!(loaded.theme, "dark");
5463 let receipt = loaded.tui_prefs_migration().expect("receipt");
5464 assert!(receipt.folded.is_empty());
5465 assert_eq!(
5466 receipt.kept,
5467 vec![("theme".to_string(), "light".to_string(), "dark".to_string())]
5468 );
5469 assert!(
5470 !receipt.lines(codewhale_localization::Locale::En).is_empty(),
5471 "a disagreement must be sayable"
5472 );
5473 }
5474
5475 #[test]
5476 fn tui_toml_keys_without_a_setting_are_quarantined_never_dropped() {
5477 let _g = config_path_test_guard();
5478 let tmp = tempfile::tempdir().expect("tempdir");
5479 let prefs_path = tmp.path().join("tui.toml");
5480 std::fs::write(
5481 &prefs_path,
5482 "theme = \"light\"\nfont_size = 14\n\n[keybinds]\nsubmit = \"ctrl+enter\"\n",
5483 )
5484 .expect("tui prefs");
5485 let _config_override =
5486 EnvVarRestore::set("DEEPSEEK_CONFIG_PATH", tmp.path().join("config.toml"));
5487
5488 let loaded = Settings::load().expect("load settings");
5489
5490 let receipt = loaded.tui_prefs_migration().expect("receipt");
5491 assert_eq!(receipt.quarantined, vec!["font_size", "keybinds"]);
5492 let backup = receipt.backup.as_ref().expect("backup path");
5493 let preserved = std::fs::read_to_string(backup).expect("backup readable");
5494 assert!(preserved.contains("font_size = 14"), "{preserved}");
5495 assert!(preserved.contains("ctrl+enter"), "{preserved}");
5496 let line = receipt
5497 .lines(codewhale_localization::Locale::En)
5498 .join(" ")
5499 .to_lowercase();
5500 assert!(line.contains("font_size"), "{line}");
5501 assert!(line.contains("keybinds"), "{line}");
5502 }
5503
5504 #[test]
5505 fn an_unparseable_tui_toml_is_parked_whole_rather_than_guessed_at() {
5506 let _g = config_path_test_guard();
5507 let tmp = tempfile::tempdir().expect("tempdir");
5508 std::fs::write(tmp.path().join("tui.toml"), "theme = \n").expect("tui prefs");
5509 let _config_override =
5510 EnvVarRestore::set("DEEPSEEK_CONFIG_PATH", tmp.path().join("config.toml"));
5511
5512 let loaded = Settings::load().expect("load settings");
5513
5514 let receipt = loaded.tui_prefs_migration().expect("receipt");
5515 assert_eq!(receipt.quarantined, vec!["tui.toml"]);
5516 assert!(receipt.folded.is_empty());
5517 assert!(receipt.backup.is_some(), "bytes must survive");
5518 }
5519
5520 #[test]
5521 fn a_read_only_load_never_moves_tui_toml_aside() {
5522 let _g = config_path_test_guard();
5523 let tmp = tempfile::tempdir().expect("tempdir");
5524 let prefs_path = tmp.path().join("tui.toml");
5525 std::fs::write(&prefs_path, "theme = \"light\"\n").expect("tui prefs");
5526 let _config_override =
5527 EnvVarRestore::set("DEEPSEEK_CONFIG_PATH", tmp.path().join("config.toml"));
5528
5529 let loaded = Settings::load_read_only().expect("read-only load");
5530
5531 assert_eq!(loaded.theme, "light");
5532 assert!(prefs_path.exists(), "diagnostics must not mutate the disk");
5533 }
5534
5535 #[test]
5536 fn a_second_backup_never_clobbers_the_first() {
5537 let tmp = tempfile::tempdir().expect("tempdir");
5538 let prefs_path = tmp.path().join("tui.toml");
5539 std::fs::write(&prefs_path, "theme = \"light\"\n").expect("first");
5540 let first = back_up_tui_prefs(&prefs_path).expect("first backup");
5541 std::fs::write(&prefs_path, "theme = \"dark\"\n").expect("second");
5542 let second = back_up_tui_prefs(&prefs_path).expect("second backup");
5543
5544 assert_ne!(first, second);
5545 assert_eq!(
5546 std::fs::read_to_string(&first).unwrap(),
5547 "theme = \"light\"\n"
5548 );
5549 assert_eq!(
5550 std::fs::read_to_string(&second).unwrap(),
5551 "theme = \"dark\"\n"
5552 );
5553 }
5554
5555 /// A successful `set()` marks the canonical key as session-supplied,
5556 /// whatever spelling was used; aliases report the same layer as the row.
5557 /// A rejected value marks nothing.
5558 #[test]
5559 fn set_marks_session_provenance_for_every_spelling() {
5560 let cases: &[(&[&str], &str)] = &[
5561 (&["auto_compact", "compact"], "true"),
5562 (
5563 &["auto_compact_threshold_percent", "auto_compact_threshold"],
5564 "80",
5565 ),
5566 (&["calm_mode", "calm"], "true"),
5567 (
5568 &["tool_collapse", "tool_collapse_mode", "collapse"],
5569 "expanded",
5570 ),
5571 (&["low_motion", "motion"], "true"),
5572 (&["fancy_animations", "fancy", "animations"], "true"),
5573 (&["focus_texture", "texture"], "grain"),
5574 (
5575 &["work_surface_placement", "work_surface", "work_rail"],
5576 "left",
5577 ),
5578 (&["rail_panel", "rail"], "tasks"),
5579 (&["work_surface_top_height", "work_top_height"], "8"),
5580 (&["work_surface_side_width", "work_side_width"], "40"),
5581 (&["bracketed_paste", "paste"], "true"),
5582 (&["paste_burst_detection", "paste_burst"], "true"),
5583 (&["mention_menu_limit", "mention_limit"], "64"),
5584 (
5585 &[
5586 "mention_walk_depth",
5587 "mention_depth",
5588 "completions_walk_depth",
5589 ],
5590 "5",
5591 ),
5592 (
5593 &["mention_menu_behavior", "mention_behavior", "mention_menu"],
5594 "fuzzy",
5595 ),
5596 (&["show_thinking", "thinking"], "true"),
5597 (&["thinking_default_expanded", "thinking_expanded"], "true"),
5598 (&["thinking_preview_lines", "thinking_preview"], "3"),
5599 (&["thinking_highlight", "reasoning_highlight"], "true"),
5600 (&["help_expand_groups", "help_expanded"], "true"),
5601 (&["pin_last_prompt", "pin_prompt"], "true"),
5602 (&["show_tool_details", "tool_details"], "true"),
5603 (&["inline_diffs", "inline_diff", "diffs"], "off"),
5604 (&["locale", "language"], "en"),
5605 (&["theme", "ui_theme"], "terminal"),
5606 (&["background_color", "background", "bg"], "#1a1b26"),
5607 (&["composer_density", "composer"], "compact"),
5608 (&["composer_border", "border"], "true"),
5609 (
5610 &["composer_multiline_mode", "multiline_mode", "multiline"],
5611 "true",
5612 ),
5613 (&["composer_vim_mode", "vim_mode", "vim"], "vim"),
5614 (&["transcript_spacing", "spacing"], "compact"),
5615 (&["status_indicator", "indicator"], "off"),
5616 (&["synchronized_output", "sync_output", "sync"], "off"),
5617 (&["workspace_follow_symlinks", "follow_symlinks"], "true"),
5618 (&["default_mode", "mode"], "plan"),
5619 (&["context_panel", "context", "session_panel"], "true"),
5620 (&["sessions_rail", "sessions_panel", "session_rail"], "true"),
5621 (&["session_auto_resume", "auto_resume"], "true"),
5622 (&["cost_currency", "currency"], "cny"),
5623 (&["max_history", "history"], "50"),
5624 (&["reasoning_effort", "effort"], "low"),
5625 (&["permission_posture", "permissions"], "ask"),
5626 (
5627 &["sandbox_mode", "sandbox", "filesystem_sandbox"],
5628 "read-only",
5629 ),
5630 ];
5631 for (spellings, value) in cases {
5632 let canonical = spellings[0];
5633 // `config set`, `/config` and `config doctor` route and suggest
5634 // from the schema by this canonical key; a `/set` spelling with
5635 // no declaration would be settable but unplaceable.
5636 assert!(
5637 codewhale_config::setting(canonical).is_some(),
5638 "`/set {canonical}` is accepted but undeclared in SETTINGS_SCHEMA"
5639 );
5640 for spelling in *spellings {
5641 assert_eq!(
5642 Settings::canonical_key(spelling),
5643 Some(canonical),
5644 "{spelling}"
5645 );
5646 let mut settings = Settings::default();
5647 assert_eq!(settings.provenance(canonical), Layer::Default);
5648 settings
5649 .set(spelling, value)
5650 .unwrap_or_else(|error| panic!("set({spelling}) rejected: {error:#}"));
5651 assert_eq!(
5652 settings.provenance(canonical),
5653 Layer::SessionOverride,
5654 "{spelling} did not mark {canonical} as session"
5655 );
5656 assert_eq!(
5657 settings.provenance(spelling),
5658 Layer::SessionOverride,
5659 "{spelling} does not resolve to its own layer"
5660 );
5661 }
5662 }
5663 // `default_model` has no provenance case above; check its spellings too.
5664 for spelling in ["default_model", "model"] {
5665 let canonical = Settings::canonical_key(spelling).expect("model spelling");
5666 assert!(codewhale_config::setting(canonical).is_some(), "{spelling}");
5667 }
5668 }
5669
5670 #[test]
5671 fn set_rejection_marks_no_provenance() {
5672 let mut settings = Settings::default();
5673 assert!(settings.set("theme", "not-a-theme").is_err());
5674 assert_eq!(settings.provenance("theme"), Layer::Default);
5675 assert!(settings.set("no_such_key", "1").is_err());
5676 assert_eq!(settings.provenance("no_such_key"), Layer::Default);
5677 }
5678
5679 /// Keys the document named load as user config; the rest are default.
5680 #[test]
5681 fn load_marks_explicit_keys_as_user_config() {
5682 let _g = config_path_test_guard();
5683 let tmp = tempfile::tempdir().expect("tempdir");
5684 std::fs::write(tmp.path().join("settings.toml"), "theme = \"light\"\n").expect("settings");
5685 let _config_override =
5686 EnvVarRestore::set("DEEPSEEK_CONFIG_PATH", tmp.path().join("config.toml"));
5687
5688 let loaded = Settings::load().expect("load settings");
5689 assert_eq!(loaded.provenance("theme"), Layer::UserConfig);
5690 assert_eq!(loaded.provenance("locale"), Layer::Default);
5691 }
5692
5693 /// `/settings` names the keys the load owns, so the text surface says
5694 /// what the user set instead of printing defaults silently.
5695 #[test]
5696 fn display_names_user_configured_keys() {
5697 let _g = config_path_test_guard();
5698 let tmp = tempfile::tempdir().expect("tempdir");
5699 std::fs::write(tmp.path().join("settings.toml"), "theme = \"light\"\n").expect("settings");
5700 let _config_override =
5701 EnvVarRestore::set("DEEPSEEK_CONFIG_PATH", tmp.path().join("config.toml"));
5702
5703 let loaded = Settings::load().expect("load settings");
5704 let text = loaded.display(codewhale_localization::Locale::En);
5705 assert!(text.contains("from settings.toml: theme"), "{text}");
5706 assert!(!text.contains("session override"), "{text}");
5707
5708 let mut session = Settings::default();
5709 session.set("locale", "en").expect("set locale");
5710 let text = session.display(codewhale_localization::Locale::En);
5711 assert!(text.contains("session override: locale"), "{text}");
5712 }
5713
5714 #[test]
5715 fn settings_save_preserves_comments() {
5716 let _g = config_path_test_guard();
5717 let tmp = std::env::temp_dir().join("dst_settings_comment_test");
5718 std::fs::create_dir_all(&tmp).unwrap();
5719 let config_file = tmp.join("config.toml");
5720 let _config_override = EnvVarRestore::set("DEEPSEEK_CONFIG_PATH", &config_file);
5721
5722 // settings.toml lives next to config.toml
5723 let settings_path = tmp.join("settings.toml");
5724 std::fs::write(
5725 &settings_path,
5726 "# my setting\ncost_currency = \"usd\"\n# trailing\n",
5727 )
5728 .unwrap();
5729
5730 // Load the existing file so we have a real struct to modify.
5731 let mut settings = Settings::load().expect("load settings");
5732 settings.cost_currency = "cny".to_string();
5733 settings.save().expect("save should succeed");
5734
5735 let body = std::fs::read_to_string(&settings_path).expect("read settings.toml");
5736 assert!(body.contains("# my setting"), "comment lost: {body}");
5737 assert!(body.contains("# trailing"), "trailing lost: {body}");
5738 assert!(body.contains("cny"), "new value not written: {body}");
5739
5740 let _ = std::fs::remove_dir_all(&tmp);
5741 }
5742
5743 #[test]
5744 fn pinned_models_are_exact_ordered_and_round_trip() {
5745 let mut settings = Settings::default();
5746 assert!(settings.toggle_pinned_model("zai", "glm-5.2"));
5747 assert!(settings.toggle_pinned_model("openrouter", "glm-5.2"));
5748 assert_eq!(settings.pinned_models[0].provider, "zai");
5749 assert!(settings.move_pinned_model("openrouter", "glm-5.2", -1));
5750 assert_eq!(settings.pinned_models[0].provider, "openrouter");
5751 assert!(settings.set_pinned_model_label("openrouter", "glm-5.2", Some("fast".to_string())));
5752 let encoded = toml::to_string(&settings).unwrap();
5753 let decoded: Settings = toml::from_str(&encoded).unwrap();
5754 assert_eq!(decoded.pinned_models, settings.pinned_models);
5755 assert!(!settings.toggle_pinned_model("openrouter", "glm-5.2"));
5756 assert_eq!(settings.pinned_models.len(), 1);
5757 }
5758 }
5759
5759 lines RUST