返回 DeepSeek-TUI-2026
settings.rs
根目录 / crates / tui / src / settings.rs
1 //! Settings system - Persistent user preferences
2 //!
3 //! Settings are stored at ~/.config/deepseek/settings.toml
4 //!
5 //! TUI-specific preferences (theme, keybinds, font_size) that survive project
6 //! switches are stored separately at ~/.deepseek/tui.toml. See [`TuiPrefs`].
7
8 use std::path::PathBuf;
9
10 use anyhow::{Context, Result};
11 use serde::{Deserialize, Serialize};
12
13 use crate::config::{expand_path, normalize_model_name};
14 use crate::localization::normalize_configured_locale;
15
16 // ============================================================================
17 // TuiPrefs — ~/.deepseek/tui.toml
18 // ============================================================================
19
20 /// TUI-specific preferences that are decoupled from agent/project config so
21 /// they survive project switches (issue #437).
22 ///
23 /// Stored at `~/.deepseek/tui.toml`. When the file is absent the values fall
24 /// back to the `[tui]` section of the normal `config.toml` (via
25 /// [`TuiPrefs::load`]), and then to the struct's own defaults.
26 ///
27 /// # Example `~/.deepseek/tui.toml`
28 ///
29 /// ```toml
30 /// theme = "dark" # "dark" | "light" | "system"
31 /// font_size = 14
32 ///
33 /// [keybinds]
34 /// submit = "ctrl+enter"
35 /// new_line = "enter"
36 /// ```
37 //
38 // NOTE: the loader is defined but not yet called from startup — wiring is
39 // deferred to a later settings pass (#657). The `#[allow(dead_code)]` suppresses the CI
40 // `-D warnings` failure until the call site lands.
41 #[allow(dead_code)]
42 #[derive(Debug, Clone, Serialize, Deserialize)]
43 #[serde(default)]
44 pub struct TuiPrefs {
45 /// UI colour theme: `"dark"` | `"light"` | `"system"`. Default `"dark"`.
46 pub theme: String,
47 /// Terminal font size hint forwarded to supporting front-ends (e.g. the
48 /// Tauri shell). `0` means "use terminal default". Default `0`.
49 pub font_size: u16,
50 /// Key-binding overrides. Each field accepts an xterm-style chord string
51 /// such as `"ctrl+enter"`, `"alt+n"`, or `"f1"`.
52 pub keybinds: KeybindPrefs,
53 }
54
55 impl Default for TuiPrefs {
56 fn default() -> Self {
57 Self {
58 theme: "dark".to_string(),
59 font_size: 0,
60 keybinds: KeybindPrefs::default(),
61 }
62 }
63 }
64
65 /// Per-action keybinding overrides stored inside [`TuiPrefs`].
66 #[allow(dead_code)] // see TuiPrefs note above; deferred to a later settings pass (#657).
67 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
68 #[serde(default)]
69 pub struct KeybindPrefs {
70 /// Key to submit the current composer input to the model.
71 /// Default: `"ctrl+enter"`.
72 pub submit: Option<String>,
73 /// Key to insert a literal newline inside the composer.
74 /// Default: `"enter"`.
75 pub new_line: Option<String>,
76 /// Key to open the command palette.
77 /// Default: `"ctrl+k"`.
78 pub command_palette: Option<String>,
79 /// Key to cancel / interrupt a running turn.
80 /// Default: `"ctrl+c"`.
81 pub cancel: Option<String>,
82 /// Key to toggle the sidebar.
83 /// Default: `"ctrl+b"`.
84 pub toggle_sidebar: Option<String>,
85 }
86
87 #[allow(dead_code)] // see TuiPrefs note above; deferred to a later settings pass (#657).
88 impl TuiPrefs {
89 /// Return the canonical path of the TUI preferences file:
90 /// `~/.deepseek/tui.toml`.
91 ///
92 /// Tests may override the home directory through the
93 /// `DEEPSEEK_CONFIG_PATH` environment variable (the parent directory of
94 /// the pointed-to config is used instead of `~/.deepseek`).
95 pub fn path() -> Result<PathBuf> {
96 // Honour the same env-var escape hatch used by Settings::path so that
97 // integration tests can redirect all config I/O to a temp directory.
98 if let Ok(config_path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
99 let config_path = config_path.trim();
100 if !config_path.is_empty() {
101 let p = expand_path(config_path);
102 if let Some(parent) = p.parent() {
103 return Ok(parent.join("tui.toml"));
104 }
105 }
106 }
107
108 let home = dirs::home_dir()
109 .context("Failed to resolve home directory: cannot determine tui.toml path.")?;
110 Ok(home.join(".deepseek").join("tui.toml"))
111 }
112
113 /// Load TUI preferences from `~/.deepseek/tui.toml`.
114 ///
115 /// If the file does not exist the struct defaults are returned — no error
116 /// is produced. Parse errors surface as `Err` so the caller can warn the
117 /// user without crashing the session.
118 pub fn load() -> Result<Self> {
119 let path = Self::path()?;
120 if !path.exists() {
121 return Ok(Self::default());
122 }
123 let content = std::fs::read_to_string(&path)
124 .with_context(|| format!("Failed to read tui.toml from {}", path.display()))?;
125 let prefs: TuiPrefs = toml::from_str(&content)
126 .with_context(|| format!("Failed to parse tui.toml from {}", path.display()))?;
127 Ok(prefs)
128 }
129
130 /// Save TUI preferences to `~/.deepseek/tui.toml`, creating the
131 /// `~/.deepseek` directory if needed.
132 pub fn save(&self) -> Result<()> {
133 let path = Self::path()?;
134 if let Some(parent) = path.parent() {
135 std::fs::create_dir_all(parent).with_context(|| {
136 format!("Failed to create config directory {}", parent.display())
137 })?;
138 }
139 let content = toml::to_string_pretty(self).context("Failed to serialize TuiPrefs")?;
140 std::fs::write(&path, content)
141 .with_context(|| format!("Failed to write tui.toml to {}", path.display()))?;
142 Ok(())
143 }
144
145 /// Validate field values and normalise them in place.
146 ///
147 /// Returns `Err` if an unrecognised `theme` value is found so callers can
148 /// surface a helpful message rather than silently ignoring a typo.
149 pub fn validate(&mut self) -> Result<()> {
150 let theme = self.theme.trim().to_ascii_lowercase();
151 match theme.as_str() {
152 "dark" | "light" | "system" => {
153 self.theme = theme;
154 }
155 other => {
156 anyhow::bail!("Invalid tui.toml theme '{other}': expected dark, light, or system.");
157 }
158 }
159 Ok(())
160 }
161 }
162
163 /// User settings with defaults
164 #[derive(Debug, Clone, Serialize, Deserialize)]
165 #[serde(default)]
166 pub struct Settings {
167 /// Auto-compact conversations when they approach the model limit.
168 pub auto_compact: bool,
169 /// Reduce status noise and collapse details more aggressively
170 pub calm_mode: bool,
171 /// Reduce animation and redraw churn
172 pub low_motion: bool,
173 /// Enable fancy footer animations (water-spout strip, pulsing text)
174 pub fancy_animations: bool,
175 /// Enable terminal bracketed-paste mode. Default true. Disable if your
176 /// terminal mishandles the `\e[?2004h` escape (rare; some legacy
177 /// terminals over SSH+screen multiplex without the cap).
178 pub bracketed_paste: bool,
179 /// Enable rapid-key paste-burst detection for terminals that do not emit
180 /// bracketed-paste events. Independent from `bracketed_paste`.
181 pub paste_burst_detection: bool,
182 /// Show thinking blocks from the model
183 pub show_thinking: bool,
184 /// Show detailed tool output
185 pub show_tool_details: bool,
186 /// UI locale: auto, en, ja, zh-Hans, pt-BR
187 pub locale: String,
188 /// Composer layout density: compact, comfortable, spacious
189 pub composer_density: String,
190 /// Show a border around the composer input area
191 pub composer_border: bool,
192 /// Composer editing mode: "normal" (default) or "vim" for modal editing.
193 /// When set to "vim" the composer starts in Normal mode; press i/a/o to
194 /// enter Insert mode and Esc to return to Normal.
195 pub composer_vim_mode: String,
196 /// Transcript spacing rhythm: compact, comfortable, spacious
197 pub transcript_spacing: String,
198 /// Default mode: "agent", "plan", "yolo"
199 pub default_mode: String,
200 /// Sidebar width as percentage of terminal width
201 pub sidebar_width_percent: u16,
202 /// Sidebar focus mode: auto, plan, todos, tasks, agents, context
203 pub sidebar_focus: String,
204 /// Enable the session-context panel (#504). Shows working set, tokens,
205 /// cost, MCP/LSP status, cycle count, and memory info.
206 pub context_panel: bool,
207 /// Cost display currency: usd or cny.
208 pub cost_currency: String,
209 /// Maximum number of input history entries to save
210 pub max_input_history: usize,
211 /// Default model to use
212 pub default_model: Option<String>,
213 }
214
215 impl Default for Settings {
216 fn default() -> Self {
217 Self {
218 // v0.8.11: default flipped to `false` to stop the engine from
219 // routinely rewriting the prompt prefix, which breaks DeepSeek
220 // V4's prefix cache (~90% discount on cached prefix tokens) and
221 // ends up costing more than the compaction itself saves. With
222 // V4's 1M-token window the user has plenty of headroom to run
223 // long sessions without auto-trimming, and the explicit
224 // `/compact` slash command + `auto_compact = on` opt-in remain
225 // available for users / agents that decide compaction is
226 // worth the cache hit on their workload (#664).
227 auto_compact: false,
228 calm_mode: false,
229 low_motion: false,
230 fancy_animations: false,
231 bracketed_paste: true,
232 paste_burst_detection: true,
233 show_thinking: true,
234 show_tool_details: true,
235 locale: "auto".to_string(),
236 composer_density: "comfortable".to_string(),
237 composer_border: true,
238 composer_vim_mode: "normal".to_string(),
239 transcript_spacing: "comfortable".to_string(),
240 default_mode: "agent".to_string(),
241 sidebar_width_percent: 28,
242 sidebar_focus: "auto".to_string(),
243 context_panel: false,
244 cost_currency: "usd".to_string(),
245 max_input_history: 100,
246 default_model: None,
247 }
248 }
249 }
250
251 impl Settings {
252 /// Get the settings file path
253 pub fn path() -> Result<PathBuf> {
254 // Allow tests to override the settings directory via the same env var
255 // used for config (DEEPSEEK_CONFIG_PATH points at config.toml; the
256 // settings file lives as a sibling in the same directory).
257 if let Ok(config_path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
258 let config_path = config_path.trim();
259 if !config_path.is_empty() {
260 let p = expand_path(config_path);
261 if let Some(parent) = p.parent() {
262 return Ok(parent.join("settings.toml"));
263 }
264 }
265 }
266
267 let config_dir = dirs::config_dir()
268 .context("Failed to resolve config directory: not found.")?
269 .join("deepseek");
270 Ok(config_dir.join("settings.toml"))
271 }
272
273 /// Load settings from disk, or return defaults if not found
274 pub fn load() -> Result<Self> {
275 let path = Self::path()?;
276 let mut settings = if !path.exists() {
277 Self::default()
278 } else {
279 let content = std::fs::read_to_string(&path)
280 .with_context(|| format!("Failed to read settings from {}", path.display()))?;
281 let mut s: Settings = toml::from_str(&content)
282 .with_context(|| format!("Failed to parse settings from {}", path.display()))?;
283 s.default_mode = normalize_mode(&s.default_mode).to_string();
284 s.composer_density = normalize_composer_density(&s.composer_density).to_string();
285 s.transcript_spacing = normalize_transcript_spacing(&s.transcript_spacing).to_string();
286 s.sidebar_focus = normalize_sidebar_focus(&s.sidebar_focus).to_string();
287 s.locale = normalize_configured_locale(&s.locale)
288 .unwrap_or("en")
289 .to_string();
290 s.default_model = s.default_model.as_deref().and_then(normalize_default_model);
291 s
292 };
293 settings.apply_env_overrides();
294 Ok(settings)
295 }
296
297 /// Apply environment-driven overlays after disk load. Used for
298 /// platform a11y signals that should ignore the user's saved
299 /// preference (#450). The env values are consulted at startup;
300 /// changing them mid-session has no effect because settings are
301 /// only re-read on `Settings::load()`.
302 pub fn apply_env_overrides(&mut self) {
303 if env_truthy("NO_ANIMATIONS") {
304 self.low_motion = true;
305 self.fancy_animations = false;
306 }
307 }
308
309 /// Save settings to disk
310 pub fn save(&self) -> Result<()> {
311 let path = Self::path()?;
312
313 // Create config directory if it doesn't exist
314 if let Some(parent) = path.parent() {
315 std::fs::create_dir_all(parent).with_context(|| {
316 format!("Failed to create config directory {}", parent.display())
317 })?;
318 }
319
320 let content = toml::to_string_pretty(self).context("Failed to serialize settings")?;
321 std::fs::write(&path, content)
322 .with_context(|| format!("Failed to write settings to {}", path.display()))?;
323 Ok(())
324 }
325
326 /// Set a single setting by key
327 pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
328 match key {
329 "auto_compact" | "compact" => {
330 self.auto_compact = parse_bool(value)?;
331 }
332 "calm_mode" | "calm" => {
333 self.calm_mode = parse_bool(value)?;
334 }
335 "low_motion" | "motion" => {
336 self.low_motion = parse_bool(value)?;
337 }
338 "fancy_animations" | "fancy" | "animations" => {
339 self.fancy_animations = parse_bool(value)?;
340 }
341 "bracketed_paste" | "paste" => {
342 self.bracketed_paste = parse_bool(value)?;
343 }
344 "paste_burst_detection" | "paste_burst" => {
345 self.paste_burst_detection = parse_bool(value)?;
346 }
347 "show_thinking" | "thinking" => {
348 self.show_thinking = parse_bool(value)?;
349 }
350 "show_tool_details" | "tool_details" => {
351 self.show_tool_details = parse_bool(value)?;
352 }
353 "locale" | "language" => {
354 let Some(locale) = normalize_configured_locale(value) else {
355 anyhow::bail!(
356 "Failed to update setting: invalid locale '{value}'. Expected: auto, en, ja, zh-Hans, pt-BR."
357 );
358 };
359 self.locale = locale.to_string();
360 }
361 "composer_density" | "composer" => {
362 let normalized = normalize_composer_density(value);
363 if !["compact", "comfortable", "spacious"].contains(&normalized) {
364 anyhow::bail!(
365 "Failed to update setting: invalid composer density '{value}'. Expected: compact, comfortable, spacious."
366 );
367 }
368 self.composer_density = normalized.to_string();
369 }
370 "composer_border" | "border" => {
371 self.composer_border = parse_bool(value)?;
372 }
373 "composer_vim_mode" | "vim_mode" | "vim" => {
374 let normalized = value.trim().to_ascii_lowercase();
375 if !["vim", "normal"].contains(&normalized.as_str()) {
376 anyhow::bail!(
377 "Failed to update setting: invalid composer vim mode '{value}'. Expected: normal, vim."
378 );
379 }
380 self.composer_vim_mode = normalized;
381 }
382 "transcript_spacing" | "spacing" => {
383 let normalized = normalize_transcript_spacing(value);
384 if !["compact", "comfortable", "spacious"].contains(&normalized) {
385 anyhow::bail!(
386 "Failed to update setting: invalid transcript spacing '{value}'. Expected: compact, comfortable, spacious."
387 );
388 }
389 self.transcript_spacing = normalized.to_string();
390 }
391 "default_mode" | "mode" => {
392 let normalized = normalize_mode(value);
393 if !["agent", "plan", "yolo"].contains(&normalized) {
394 anyhow::bail!(
395 "Failed to update setting: invalid mode '{value}'. Expected: agent, plan, yolo."
396 );
397 }
398 self.default_mode = normalized.to_string();
399 }
400 "sidebar_width" | "sidebar" => {
401 let width: u16 = value
402 .parse()
403 .map_err(|_| {
404 anyhow::anyhow!(
405 "Failed to update setting: invalid width '{value}'. Expected a number between 10-50."
406 )
407 })?;
408 if !(10..=50).contains(&width) {
409 anyhow::bail!(
410 "Failed to update setting: width must be between 10 and 50 percent."
411 );
412 }
413 self.sidebar_width_percent = width;
414 }
415 "sidebar_focus" | "focus" => {
416 let normalized = match value.trim().to_ascii_lowercase().as_str() {
417 "auto" => "auto",
418 "plan" => "plan",
419 "todos" => "todos",
420 "tasks" => "tasks",
421 "agents" | "subagents" | "sub-agents" => "agents",
422 _ => {
423 anyhow::bail!(
424 "Failed to update setting: invalid sidebar focus '{value}'. Expected: auto, plan, todos, tasks, agents."
425 )
426 }
427 };
428 self.sidebar_focus = normalized.to_string();
429 }
430 "cost_currency" | "currency" => {
431 let Some(currency) = crate::pricing::CostCurrency::from_setting(value) else {
432 anyhow::bail!(
433 "Failed to update setting: invalid cost currency '{value}'. Expected: usd, cny, rmb, yuan."
434 );
435 };
436 self.cost_currency = match currency {
437 crate::pricing::CostCurrency::Usd => "usd",
438 crate::pricing::CostCurrency::Cny => "cny",
439 }
440 .to_string();
441 }
442 "max_history" | "history" => {
443 let max: usize = value.parse().map_err(|_| {
444 anyhow::anyhow!(
445 "Failed to update setting: invalid max history '{value}'. Expected a positive number."
446 )
447 })?;
448 self.max_input_history = max;
449 }
450 "default_model" | "model" => {
451 let trimmed = value.trim();
452 if trimmed.is_empty()
453 || matches!(
454 trimmed.to_ascii_lowercase().as_str(),
455 "none" | "default" | "(default)"
456 )
457 {
458 self.default_model = None;
459 return Ok(());
460 }
461
462 let Some(model) = normalize_default_model(trimmed) else {
463 anyhow::bail!(
464 "Failed to update setting: invalid model '{value}'. Expected: auto, a DeepSeek model ID (for example deepseek-v4-pro, deepseek-v4-flash), or none/default."
465 );
466 };
467 self.default_model = Some(model);
468 }
469 _ => {
470 anyhow::bail!("Failed to update setting: unknown setting '{key}'.");
471 }
472 }
473 Ok(())
474 }
475
476 /// Get all settings as a displayable string
477 pub fn display(&self, locale: crate::localization::Locale) -> String {
478 use crate::localization::{MessageId, tr};
479 let mut lines = Vec::new();
480 lines.push(tr(locale, MessageId::SettingsTitle).to_string());
481 lines.push("─────────────────────────────".to_string());
482 lines.push(format!(" auto_compact: {}", self.auto_compact));
483 lines.push(format!(" calm_mode: {}", self.calm_mode));
484 lines.push(format!(" low_motion: {}", self.low_motion));
485 lines.push(format!(" fancy_animations: {}", self.fancy_animations));
486 lines.push(format!(" bracketed_paste: {}", self.bracketed_paste));
487 lines.push(format!(
488 " paste_burst_detect: {}",
489 self.paste_burst_detection
490 ));
491 lines.push(format!(" show_thinking: {}", self.show_thinking));
492 lines.push(format!(" show_tool_details: {}", self.show_tool_details));
493 lines.push(format!(" locale: {}", self.locale));
494 lines.push(format!(" composer_density: {}", self.composer_density));
495 lines.push(format!(" composer_border: {}", self.composer_border));
496 lines.push(format!(" composer_vim_mode: {}", self.composer_vim_mode));
497 lines.push(format!(" transcript_spacing: {}", self.transcript_spacing));
498 lines.push(format!(" default_mode: {}", self.default_mode));
499 lines.push(format!(
500 " sidebar_width: {}%",
501 self.sidebar_width_percent
502 ));
503 lines.push(format!(" sidebar_focus: {}", self.sidebar_focus));
504 lines.push(format!(" cost_currency: {}", self.cost_currency));
505 lines.push(format!(" max_history: {}", self.max_input_history));
506 lines.push(format!(
507 " default_model: {}",
508 self.default_model.as_deref().unwrap_or("(default)")
509 ));
510 lines.push(String::new());
511 lines.push(format!(
512 "{} {}",
513 tr(locale, MessageId::SettingsConfigFile),
514 Self::path().map_or_else(|_| "(unknown)".to_string(), |p| p.display().to_string())
515 ));
516 lines.join("\n")
517 }
518
519 /// Get available setting keys and their descriptions
520 #[allow(dead_code)]
521 pub fn available_settings() -> Vec<(&'static str, &'static str)> {
522 vec![
523 (
524 "auto_compact",
525 "Auto-compact near context limit: on/off (default on)",
526 ),
527 ("calm_mode", "Calmer UI defaults: on/off"),
528 ("low_motion", "Reduce animation and redraw churn: on/off"),
529 (
530 "fancy_animations",
531 "Fancy footer animations (water-spout strip): on/off",
532 ),
533 (
534 "bracketed_paste",
535 "Terminal bracketed-paste mode: on/off (rare to disable)",
536 ),
537 (
538 "paste_burst_detection",
539 "Fallback rapid-key paste detection: on/off",
540 ),
541 ("show_thinking", "Show model thinking: on/off"),
542 ("show_tool_details", "Show detailed tool output: on/off"),
543 (
544 "locale",
545 "UI locale: auto, en, ja, zh-Hans, pt-BR (model output is unchanged)",
546 ),
547 (
548 "composer_density",
549 "Composer density: compact, comfortable, spacious",
550 ),
551 (
552 "composer_border",
553 "Show a border around the composer input area: on/off",
554 ),
555 (
556 "transcript_spacing",
557 "Transcript spacing: compact, comfortable, spacious",
558 ),
559 ("default_mode", "Default mode: agent, plan, yolo"),
560 ("sidebar_width", "Sidebar width percentage: 10-50"),
561 (
562 "sidebar_focus",
563 "Sidebar focus: auto, plan, todos, tasks, agents",
564 ),
565 ("cost_currency", "Cost display currency: usd, cny"),
566 ("max_history", "Max input history entries"),
567 (
568 "default_model",
569 "Default model: auto or any DeepSeek model ID (e.g. deepseek-v4-pro)",
570 ),
571 ]
572 }
573 }
574
575 fn normalize_default_model(value: &str) -> Option<String> {
576 let trimmed = value.trim();
577 if trimmed.eq_ignore_ascii_case("auto") {
578 Some("auto".to_string())
579 } else {
580 normalize_model_name(trimmed)
581 }
582 }
583
584 /// Parse a boolean value from various formats
585 fn parse_bool(value: &str) -> Result<bool> {
586 match value.to_lowercase().as_str() {
587 "on" | "true" | "yes" | "1" | "enabled" => Ok(true),
588 "off" | "false" | "no" | "0" | "disabled" => Ok(false),
589 _ => {
590 anyhow::bail!("Failed to parse boolean '{value}': expected on/off, true/false, yes/no.")
591 }
592 }
593 }
594
595 fn normalize_mode(value: &str) -> &str {
596 match value.trim().to_ascii_lowercase().as_str() {
597 "edit" => "agent",
598 "normal" => "agent",
599 "agent" => "agent",
600 "plan" => "plan",
601 "yolo" => "yolo",
602 _ => value,
603 }
604 }
605
606 fn normalize_composer_density(value: &str) -> &str {
607 match value.trim().to_ascii_lowercase().as_str() {
608 "compact" | "tight" => "compact",
609 "comfortable" | "default" | "normal" => "comfortable",
610 "spacious" | "loose" => "spacious",
611 _ => value,
612 }
613 }
614
615 fn normalize_transcript_spacing(value: &str) -> &str {
616 match value.trim().to_ascii_lowercase().as_str() {
617 "compact" | "tight" => "compact",
618 "comfortable" | "default" | "normal" => "comfortable",
619 "spacious" | "loose" => "spacious",
620 _ => value,
621 }
622 }
623
624 fn normalize_sidebar_focus(value: &str) -> &str {
625 match value.trim().to_ascii_lowercase().as_str() {
626 "plan" => "plan",
627 "todos" => "todos",
628 "tasks" => "tasks",
629 "agents" | "subagents" | "sub-agents" => "agents",
630 "context" | "session" => "context",
631 _ => "auto",
632 }
633 }
634
635 /// Resolve an environment variable as a boolean. Recognises the
636 /// common truthy spellings (`1`, `true`, `yes`, `on`) case-
637 /// insensitively. Used by [`Settings::apply_env_overrides`] for
638 /// platform a11y signals like `NO_ANIMATIONS`.
639 fn env_truthy(name: &str) -> bool {
640 match std::env::var(name) {
641 Ok(v) => matches!(
642 v.trim().to_ascii_lowercase().as_str(),
643 "1" | "true" | "yes" | "on"
644 ),
645 Err(_) => false,
646 }
647 }
648
649 #[cfg(test)]
650 mod tests {
651 use super::*;
652
653 #[test]
654 fn default_settings_disable_auto_compact_to_protect_v4_prefix_cache() {
655 let settings = Settings::default();
656 // v0.8.11: default is `false` to stop the engine from routinely
657 // rewriting the prompt prefix, which breaks V4's prefix-cache
658 // discount. The explicit `/compact` command and the
659 // `auto_compact = on` opt-in stay available; the default is
660 // flipped so the cache-friendly path is the one users get
661 // without configuring anything (#664).
662 assert!(!settings.auto_compact);
663 }
664
665 #[test]
666 fn auto_compact_remains_explicitly_configurable() {
667 let mut settings = Settings::default();
668 settings.set("auto_compact", "on").expect("enable");
669 assert!(settings.auto_compact);
670 settings.set("auto_compact", "off").expect("disable");
671 assert!(!settings.auto_compact);
672 }
673
674 #[test]
675 fn paste_burst_detection_is_configurable_independent_of_bracketed_paste() {
676 let mut settings = Settings::default();
677 assert!(settings.bracketed_paste);
678 assert!(settings.paste_burst_detection);
679
680 settings
681 .set("paste_burst_detection", "off")
682 .expect("disable paste burst fallback");
683 assert!(settings.bracketed_paste);
684 assert!(!settings.paste_burst_detection);
685
686 settings
687 .set("bracketed_paste", "off")
688 .expect("disable bracketed paste");
689 assert!(!settings.bracketed_paste);
690 assert!(!settings.paste_burst_detection);
691 }
692
693 #[test]
694 fn locale_normalizes_supported_values_and_rejects_unknowns() {
695 let mut settings = Settings::default();
696 settings.set("locale", "ja_JP.UTF-8").expect("set ja");
697 assert_eq!(settings.locale, "ja");
698
699 settings.set("language", "pt-PT").expect("set pt fallback");
700 assert_eq!(settings.locale, "pt-BR");
701
702 let err = settings
703 .set("locale", "ar")
704 .expect_err("Arabic is planned, not shipped");
705 assert!(err.to_string().contains("invalid locale"));
706 }
707
708 #[test]
709 fn cost_currency_normalizes_yuan_aliases_and_rejects_unknowns() {
710 let mut settings = Settings::default();
711 assert_eq!(settings.cost_currency, "usd");
712
713 settings.set("cost_currency", "yuan").expect("set yuan");
714 assert_eq!(settings.cost_currency, "cny");
715
716 settings.set("currency", "rmb").expect("set rmb");
717 assert_eq!(settings.cost_currency, "cny");
718
719 let err = settings
720 .set("cost_currency", "eur")
721 .expect_err("unsupported currency");
722 assert!(err.to_string().contains("invalid cost currency"));
723 }
724
725 #[test]
726 fn display_localizes_header_and_config_file_label() {
727 let settings = Settings::default();
728 let en = settings.display(crate::localization::Locale::En);
729 assert!(en.contains("Settings:"), "english header missing:\n{en}");
730 assert!(
731 en.contains("Config file:"),
732 "english config label missing:\n{en}"
733 );
734
735 let zh = settings.display(crate::localization::Locale::ZhHans);
736 assert!(zh.contains("设置"), "chinese header missing:\n{zh}");
737 assert!(
738 zh.contains("配置文件"),
739 "chinese config label missing:\n{zh}"
740 );
741 }
742
743 /// Tests that mutate process-global `NO_ANIMATIONS` serialise
744 /// through this guard so the cargo parallel runner doesn't
745 /// observe interleaved overrides.
746 fn no_animations_test_guard() -> std::sync::MutexGuard<'static, ()> {
747 static GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
748 GUARD.lock().unwrap_or_else(|e| e.into_inner())
749 }
750
751 #[test]
752 fn no_animations_env_forces_low_motion_on() {
753 let _g = no_animations_test_guard();
754 // SAFETY: tests in this group serialise through the guard.
755 unsafe {
756 std::env::set_var("NO_ANIMATIONS", "1");
757 }
758 let mut settings = Settings::default();
759 assert!(!settings.low_motion, "default is animated");
760 assert!(!settings.fancy_animations, "default is animated");
761 settings.apply_env_overrides();
762 assert!(settings.low_motion, "NO_ANIMATIONS=1 forces low_motion");
763 assert!(
764 !settings.fancy_animations,
765 "NO_ANIMATIONS=1 keeps fancy off"
766 );
767 // SAFETY: cleanup under the guard.
768 unsafe {
769 std::env::remove_var("NO_ANIMATIONS");
770 }
771 }
772
773 #[test]
774 fn no_animations_env_overrides_user_opt_in() {
775 let _g = no_animations_test_guard();
776 // SAFETY: serialised by the guard.
777 unsafe {
778 std::env::set_var("NO_ANIMATIONS", "true");
779 }
780 // User had explicitly opted into fancy animations on disk.
781 let mut settings = Settings {
782 fancy_animations: true,
783 ..Settings::default()
784 };
785 settings.apply_env_overrides();
786 assert!(
787 !settings.fancy_animations,
788 "platform NO_ANIMATIONS overrides user-opt-in fancy_animations"
789 );
790 assert!(settings.low_motion);
791 // SAFETY: cleanup under the guard.
792 unsafe {
793 std::env::remove_var("NO_ANIMATIONS");
794 }
795 }
796
797 #[test]
798 fn no_animations_env_recognises_truthy_spellings_only() {
799 let _g = no_animations_test_guard();
800 for truthy in ["1", "true", "True", "YES", "on"] {
801 // SAFETY: serialised by the guard.
802 unsafe {
803 std::env::set_var("NO_ANIMATIONS", truthy);
804 }
805 let mut s = Settings::default();
806 s.apply_env_overrides();
807 assert!(s.low_motion, "{truthy:?} should be truthy");
808 }
809 for falsy in ["0", "false", "no", "off", ""] {
810 // SAFETY: serialised by the guard.
811 unsafe {
812 std::env::set_var("NO_ANIMATIONS", falsy);
813 }
814 let mut s = Settings::default();
815 s.apply_env_overrides();
816 assert!(!s.low_motion, "{falsy:?} should be falsy");
817 }
818 // SAFETY: cleanup under the guard.
819 unsafe {
820 std::env::remove_var("NO_ANIMATIONS");
821 }
822 }
823
824 // ────────────────────────────────────────────────────────────────────────
825 // TuiPrefs tests
826 // ────────────────────────────────────────────────────────────────────────
827
828 /// Serialise tests that mutate `DEEPSEEK_CONFIG_PATH` through this guard
829 /// so the parallel test runner doesn't observe interleaved env values.
830 fn config_path_test_guard() -> std::sync::MutexGuard<'static, ()> {
831 static GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
832 GUARD.lock().unwrap_or_else(|e| e.into_inner())
833 }
834
835 #[test]
836 fn tui_prefs_defaults_are_dark_theme_zero_font() {
837 let prefs = TuiPrefs::default();
838 assert_eq!(prefs.theme, "dark");
839 assert_eq!(prefs.font_size, 0);
840 assert!(prefs.keybinds.submit.is_none());
841 assert!(prefs.keybinds.new_line.is_none());
842 }
843
844 #[test]
845 fn tui_prefs_validate_accepts_known_themes() {
846 for theme in ["dark", "light", "system"] {
847 let mut prefs = TuiPrefs {
848 theme: theme.to_string(),
849 ..TuiPrefs::default()
850 };
851 prefs
852 .validate()
853 .unwrap_or_else(|e| panic!("validate({theme}) failed: {e}"));
854 assert_eq!(prefs.theme, theme);
855 }
856 }
857
858 #[test]
859 fn tui_prefs_validate_normalises_theme_case() {
860 let mut prefs = TuiPrefs {
861 theme: "DARK".to_string(),
862 ..TuiPrefs::default()
863 };
864 prefs.validate().expect("DARK should normalise to dark");
865 assert_eq!(prefs.theme, "dark");
866 }
867
868 #[test]
869 fn tui_prefs_validate_rejects_unknown_theme() {
870 let mut prefs = TuiPrefs {
871 theme: "solarized".to_string(),
872 ..TuiPrefs::default()
873 };
874 let err = prefs
875 .validate()
876 .expect_err("solarized is not a valid theme");
877 assert!(err.to_string().contains("Invalid tui.toml theme"));
878 }
879
880 #[test]
881 fn tui_prefs_round_trips_through_toml() {
882 let prefs = TuiPrefs {
883 theme: "light".to_string(),
884 font_size: 16,
885 keybinds: KeybindPrefs {
886 submit: Some("ctrl+enter".to_string()),
887 new_line: Some("enter".to_string()),
888 command_palette: None,
889 cancel: None,
890 toggle_sidebar: None,
891 },
892 };
893 let serialised = toml::to_string_pretty(&prefs).expect("serialise");
894 let de: TuiPrefs = toml::from_str(&serialised).expect("deserialise");
895 assert_eq!(de.theme, "light");
896 assert_eq!(de.font_size, 16);
897 assert_eq!(de.keybinds.submit.as_deref(), Some("ctrl+enter"));
898 assert_eq!(de.keybinds.new_line.as_deref(), Some("enter"));
899 assert!(de.keybinds.command_palette.is_none());
900 }
901
902 #[test]
903 fn tui_prefs_load_returns_defaults_when_file_absent() {
904 let _g = config_path_test_guard();
905 // Point config path at a non-existent location so tui.toml is absent.
906 let tmp = std::env::temp_dir().join("dst_tui_prefs_absent_test");
907 std::fs::create_dir_all(&tmp).unwrap();
908 // SAFETY: test-only env mutation guarded by config_path_test_guard.
909 unsafe {
910 std::env::set_var(
911 "DEEPSEEK_CONFIG_PATH",
912 tmp.join("config.toml").to_str().unwrap(),
913 );
914 }
915 let prefs = TuiPrefs::load().expect("load should not fail when file absent");
916 assert_eq!(prefs.theme, "dark", "should fall back to default theme");
917 // SAFETY: cleanup under the guard.
918 unsafe {
919 std::env::remove_var("DEEPSEEK_CONFIG_PATH");
920 }
921 let _ = std::fs::remove_dir_all(&tmp);
922 }
923
924 #[test]
925 fn tui_prefs_save_and_load_round_trip() {
926 let _g = config_path_test_guard();
927 let tmp = std::env::temp_dir().join("dst_tui_prefs_save_test");
928 std::fs::create_dir_all(&tmp).unwrap();
929 // SAFETY: test-only env mutation guarded by config_path_test_guard.
930 unsafe {
931 std::env::set_var(
932 "DEEPSEEK_CONFIG_PATH",
933 tmp.join("config.toml").to_str().unwrap(),
934 );
935 }
936
937 let prefs = TuiPrefs {
938 theme: "light".to_string(),
939 font_size: 14,
940 keybinds: KeybindPrefs {
941 submit: Some("ctrl+enter".to_string()),
942 ..KeybindPrefs::default()
943 },
944 };
945 prefs.save().expect("save should succeed");
946
947 let loaded = TuiPrefs::load().expect("load after save");
948 assert_eq!(loaded.theme, "light");
949 assert_eq!(loaded.font_size, 14);
950 assert_eq!(loaded.keybinds.submit.as_deref(), Some("ctrl+enter"));
951
952 // SAFETY: cleanup under the guard.
953 unsafe {
954 std::env::remove_var("DEEPSEEK_CONFIG_PATH");
955 }
956 let _ = std::fs::remove_dir_all(&tmp);
957 }
958
959 #[test]
960 fn tui_prefs_path_uses_home_deepseek_subdir_by_default() {
961 let _g = config_path_test_guard();
962 // Without DEEPSEEK_CONFIG_PATH the path should end with
963 // .deepseek/tui.toml relative to the home directory.
964 // We skip this check if home_dir() is unavailable (CI without HOME).
965 if let Some(home) = dirs::home_dir() {
966 let expected = home.join(".deepseek").join("tui.toml");
967 // Only compare when no env override is active.
968 if std::env::var("DEEPSEEK_CONFIG_PATH").is_err() {
969 let got = TuiPrefs::path().expect("path should resolve");
970 assert_eq!(got, expected);
971 }
972 }
973 }
974 }
975
975 lines RUST