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