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