use std::path::PathBuf;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::config::{expand_path, normalize_model_name};
use crate::localization::normalize_configured_locale;
use crate::palette::{normalize_hex_rgb_color, normalize_theme_name};
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TuiPrefs {
pub theme: String,
pub font_size: u16,
pub keybinds: KeybindPrefs,
}
impl Default for TuiPrefs {
fn default() -> Self {
Self {
theme: "dark".to_string(),
font_size: 0,
keybinds: KeybindPrefs::default(),
}
}
}
#[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct KeybindPrefs {
pub submit: Option<String>,
pub new_line: Option<String>,
pub command_palette: Option<String>,
pub cancel: Option<String>,
pub toggle_sidebar: Option<String>,
}
#[allow(dead_code)] impl TuiPrefs {
pub fn path() -> Result<PathBuf> {
if let Ok(config_path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
let config_path = config_path.trim();
if !config_path.is_empty() {
let p = expand_path(config_path);
if let Some(parent) = p.parent() {
return Ok(parent.join("tui.toml"));
}
}
}
let home = dirs::home_dir()
.context("Failed to resolve home directory: cannot determine tui.toml path.")?;
Ok(home.join(".deepseek").join("tui.toml"))
}
pub fn load() -> Result<Self> {
let path = Self::path()?;
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read tui.toml from {}", path.display()))?;
let prefs: TuiPrefs = toml::from_str(&content)
.with_context(|| format!("Failed to parse tui.toml from {}", path.display()))?;
Ok(prefs)
}
pub fn save(&self) -> Result<()> {
let path = Self::path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("Failed to create config directory {}", parent.display())
})?;
}
let content = toml::to_string_pretty(self).context("Failed to serialize TuiPrefs")?;
std::fs::write(&path, content)
.with_context(|| format!("Failed to write tui.toml to {}", path.display()))?;
Ok(())
}
pub fn validate(&mut self) -> Result<()> {
let theme = self.theme.trim().to_ascii_lowercase();
let Some(theme) = normalize_theme_name(&theme) else {
anyhow::bail!(
"Invalid tui.toml theme '{}': expected system, dark, light, grayscale, catppuccin-mocha, tokyo-night, dracula, or gruvbox-dark.",
self.theme
);
};
self.theme = theme.to_string();
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
pub auto_compact: bool,
pub calm_mode: bool,
pub low_motion: bool,
pub fancy_animations: bool,
pub bracketed_paste: bool,
pub paste_burst_detection: bool,
pub show_thinking: bool,
pub show_tool_details: bool,
pub locale: String,
pub theme: String,
pub background_color: Option<String>,
pub composer_density: String,
pub composer_border: bool,
pub composer_vim_mode: String,
pub transcript_spacing: String,
pub default_mode: String,
pub sidebar_width_percent: u16,
pub sidebar_focus: String,
pub context_panel: bool,
pub cost_currency: String,
pub max_input_history: usize,
pub default_provider: Option<String>,
pub default_model: Option<String>,
pub reasoning_effort: Option<String>,
pub provider_models: Option<std::collections::HashMap<String, String>>,
pub status_indicator: String,
pub synchronized_output: String,
pub prefer_external_pdftotext: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
auto_compact: false,
calm_mode: false,
low_motion: false,
fancy_animations: true,
bracketed_paste: true,
paste_burst_detection: true,
show_thinking: true,
show_tool_details: true,
locale: "auto".to_string(),
theme: "system".to_string(),
background_color: None,
composer_density: "comfortable".to_string(),
composer_border: true,
composer_vim_mode: "normal".to_string(),
transcript_spacing: "comfortable".to_string(),
default_mode: "agent".to_string(),
sidebar_width_percent: 28,
sidebar_focus: "auto".to_string(),
context_panel: false,
cost_currency: "usd".to_string(),
max_input_history: 100,
default_provider: None,
default_model: None,
reasoning_effort: None,
provider_models: None,
status_indicator: "whale".to_string(),
synchronized_output: "auto".to_string(),
prefer_external_pdftotext: false,
}
}
}
impl Settings {
pub fn path() -> Result<PathBuf> {
if let Ok(config_path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
let config_path = config_path.trim();
if !config_path.is_empty() {
let p = expand_path(config_path);
if let Some(parent) = p.parent() {
return Ok(parent.join("settings.toml"));
}
}
}
let config_dir = dirs::config_dir()
.context("Failed to resolve config directory: not found.")?
.join("deepseek");
Ok(config_dir.join("settings.toml"))
}
pub fn load() -> Result<Self> {
let path = Self::path()?;
let mut settings = if !path.exists() {
Self::default()
} else {
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read settings from {}", path.display()))?;
let mut s: Settings = toml::from_str(&content)
.with_context(|| format!("Failed to parse settings from {}", path.display()))?;
s.default_mode = normalize_mode(&s.default_mode).to_string();
s.composer_density = normalize_composer_density(&s.composer_density).to_string();
s.transcript_spacing = normalize_transcript_spacing(&s.transcript_spacing).to_string();
s.sidebar_focus = normalize_sidebar_focus(&s.sidebar_focus).to_string();
s.status_indicator = normalize_status_indicator(&s.status_indicator).to_string();
s.synchronized_output =
normalize_synchronized_output(&s.synchronized_output).to_string();
s.locale = normalize_configured_locale(&s.locale)
.unwrap_or("en")
.to_string();
s.background_color = normalize_optional_background_color(s.background_color.as_deref());
s.theme = normalize_settings_theme(&s.theme).to_string();
s.default_model = s.default_model.as_deref().and_then(normalize_default_model);
s.reasoning_effort = s
.reasoning_effort
.as_deref()
.and_then(|value| normalize_reasoning_effort_setting(value).ok().flatten());
s
};
settings.apply_env_overrides();
Ok(settings)
}
pub fn apply_env_overrides(&mut self) {
if env_truthy("NO_ANIMATIONS") {
self.low_motion = true;
self.fancy_animations = false;
}
let vte_env_forces_low_motion = std::env::var_os("TILIX_ID").is_some_and(|v| !v.is_empty())
|| std::env::var_os("TERMINATOR_UUID").is_some_and(|v| !v.is_empty());
if matches!(
std::env::var("TERM_PROGRAM").as_deref(),
Ok("vscode") | Ok("ghostty")
) || vte_env_forces_low_motion
{
self.low_motion = true;
self.fancy_animations = false;
}
let term_is_termius = std::env::var("TERM_PROGRAM").as_deref() == Ok("Termius");
let in_ssh_session = std::env::var_os("SSH_CLIENT").is_some_and(|v| !v.is_empty())
|| std::env::var_os("SSH_TTY").is_some_and(|v| !v.is_empty());
if term_is_termius || in_ssh_session {
self.low_motion = true;
self.fancy_animations = false;
}
let in_terminal_multiplexer = std::env::var_os("TMUX").is_some_and(|v| !v.is_empty())
|| std::env::var_os("STY").is_some_and(|v| !v.is_empty());
if in_terminal_multiplexer {
self.low_motion = true;
self.fancy_animations = false;
}
if detected_legacy_windows_console_host() {
self.low_motion = true;
self.fancy_animations = false;
if self.synchronized_output.eq_ignore_ascii_case("auto") {
self.synchronized_output = "off".to_string();
}
}
if self.synchronized_output.eq_ignore_ascii_case("auto") && detected_ptyxis_terminal() {
self.synchronized_output = "off".to_string();
}
}
pub fn save(&self) -> Result<()> {
let path = Self::path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("Failed to create config directory {}", parent.display())
})?;
}
let content = toml::to_string_pretty(self).context("Failed to serialize settings")?;
std::fs::write(&path, content)
.with_context(|| format!("Failed to write settings to {}", path.display()))?;
Ok(())
}
pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
match key {
"auto_compact" | "compact" => {
self.auto_compact = parse_bool(value)?;
}
"calm_mode" | "calm" => {
self.calm_mode = parse_bool(value)?;
}
"low_motion" | "motion" => {
self.low_motion = parse_bool(value)?;
}
"fancy_animations" | "fancy" | "animations" => {
self.fancy_animations = parse_bool(value)?;
}
"bracketed_paste" | "paste" => {
self.bracketed_paste = parse_bool(value)?;
}
"paste_burst_detection" | "paste_burst" => {
self.paste_burst_detection = parse_bool(value)?;
}
"show_thinking" | "thinking" => {
self.show_thinking = parse_bool(value)?;
}
"show_tool_details" | "tool_details" => {
self.show_tool_details = parse_bool(value)?;
}
"locale" | "language" => {
let Some(locale) = normalize_configured_locale(value) else {
anyhow::bail!(
"Failed to update setting: invalid locale '{value}'. Expected: auto, en, ja, zh-Hans, pt-BR, es-419."
);
};
self.locale = locale.to_string();
}
"theme" => {
let Some(id) = crate::palette::ThemeId::from_name(value) else {
anyhow::bail!(
"Failed to update setting: invalid theme '{value}'. Expected: system, dark, light, grayscale, catppuccin-mocha, tokyo-night, dracula, gruvbox-dark."
);
};
self.theme = id.name().to_string();
}
"ui_theme" => {
let Some(id) = crate::palette::ThemeId::from_name(value) else {
anyhow::bail!(
"Failed to update setting: invalid theme '{value}'. Expected: system, dark, light, grayscale, catppuccin-mocha, tokyo-night, dracula, gruvbox-dark."
);
};
self.theme = id.name().to_string();
}
"background_color" | "background" | "bg" => {
self.background_color = normalize_background_color_setting(value)?;
}
"composer_density" | "composer" => {
let normalized = normalize_composer_density(value);
if !["compact", "comfortable", "spacious"].contains(&normalized) {
anyhow::bail!(
"Failed to update setting: invalid composer density '{value}'. Expected: compact, comfortable, spacious."
);
}
self.composer_density = normalized.to_string();
}
"composer_border" | "border" => {
self.composer_border = parse_bool(value)?;
}
"composer_vim_mode" | "vim_mode" | "vim" => {
let normalized = value.trim().to_ascii_lowercase();
if !["vim", "normal"].contains(&normalized.as_str()) {
anyhow::bail!(
"Failed to update setting: invalid composer vim mode '{value}'. Expected: normal, vim."
);
}
self.composer_vim_mode = normalized;
}
"transcript_spacing" | "spacing" => {
let normalized = normalize_transcript_spacing(value);
if !["compact", "comfortable", "spacious"].contains(&normalized) {
anyhow::bail!(
"Failed to update setting: invalid transcript spacing '{value}'. Expected: compact, comfortable, spacious."
);
}
self.transcript_spacing = normalized.to_string();
}
"status_indicator" | "indicator" => {
let normalized = normalize_status_indicator(value);
if !["whale", "dots", "off"].contains(&normalized) {
anyhow::bail!(
"Failed to update setting: invalid status indicator '{value}'. Expected: whale, dots, off."
);
}
self.status_indicator = normalized.to_string();
}
"synchronized_output" | "sync_output" | "sync" => {
let normalized = normalize_synchronized_output(value);
if !["auto", "on", "off"].contains(&normalized) {
anyhow::bail!(
"Failed to update setting: invalid synchronized_output '{value}'. Expected: auto, on, off."
);
}
self.synchronized_output = normalized.to_string();
}
"prefer_external_pdftotext" | "external_pdftotext" | "pdftotext" => {
self.prefer_external_pdftotext = parse_bool(value)?;
}
"default_mode" | "mode" => {
let normalized = normalize_mode(value);
if !["agent", "plan", "yolo"].contains(&normalized) {
anyhow::bail!(
"Failed to update setting: invalid mode '{value}'. Expected: agent, plan, yolo."
);
}
self.default_mode = normalized.to_string();
}
"sidebar_width" | "sidebar" => {
let width: u16 = value
.parse()
.map_err(|_| {
anyhow::anyhow!(
"Failed to update setting: invalid width '{value}'. Expected a number between 10-50."
)
})?;
if !(10..=50).contains(&width) {
anyhow::bail!(
"Failed to update setting: width must be between 10 and 50 percent."
);
}
self.sidebar_width_percent = width;
}
"sidebar_focus" | "focus" => {
let normalized = match value.trim().to_ascii_lowercase().as_str() {
"auto" => "auto",
"work" | "plan" | "todos" => "work",
"tasks" => "tasks",
"agents" | "subagents" | "sub-agents" => "agents",
"context" | "session" => "context",
"hidden" | "hide" | "closed" | "off" | "none" => "hidden",
_ => {
anyhow::bail!(
"Failed to update setting: invalid sidebar focus '{value}'. Expected: auto, work, tasks, agents, context, hidden."
)
}
};
self.sidebar_focus = normalized.to_string();
}
"context_panel" | "context" | "session_panel" => {
self.context_panel = parse_bool(value)?;
}
"cost_currency" | "currency" => {
let Some(currency) = crate::pricing::CostCurrency::from_setting(value) else {
anyhow::bail!(
"Failed to update setting: invalid cost currency '{value}'. Expected: usd, cny, rmb, yuan."
);
};
self.cost_currency = match currency {
crate::pricing::CostCurrency::Usd => "usd",
crate::pricing::CostCurrency::Cny => "cny",
}
.to_string();
}
"max_history" | "history" => {
let max: usize = value.parse().map_err(|_| {
anyhow::anyhow!(
"Failed to update setting: invalid max history '{value}'. Expected a positive number."
)
})?;
self.max_input_history = max;
}
"default_model" | "model" => {
let trimmed = value.trim();
if trimmed.is_empty()
|| matches!(
trimmed.to_ascii_lowercase().as_str(),
"none" | "default" | "(default)"
)
{
self.default_model = None;
return Ok(());
}
let Some(model) = normalize_default_model(trimmed) else {
anyhow::bail!(
"Failed to update setting: invalid model '{value}'. Expected: auto, a DeepSeek model ID (for example deepseek-v4-pro, deepseek-v4-flash), or none/default."
);
};
self.default_model = Some(model);
}
"reasoning_effort" | "effort" => {
self.reasoning_effort = normalize_reasoning_effort_setting(value)?;
}
_ => {
anyhow::bail!("Failed to update setting: unknown setting '{key}'.");
}
}
Ok(())
}
pub fn display(&self, locale: crate::localization::Locale) -> String {
use crate::localization::{MessageId, tr};
let mut lines = Vec::new();
lines.push(tr(locale, MessageId::SettingsTitle).to_string());
lines.push("─────────────────────────────".to_string());
lines.push(format!(" auto_compact: {}", self.auto_compact));
lines.push(format!(" calm_mode: {}", self.calm_mode));
lines.push(format!(" low_motion: {}", self.low_motion));
lines.push(format!(" fancy_animations: {}", self.fancy_animations));
lines.push(format!(" bracketed_paste: {}", self.bracketed_paste));
lines.push(format!(
" paste_burst_detect: {}",
self.paste_burst_detection
));
lines.push(format!(" show_thinking: {}", self.show_thinking));
lines.push(format!(" show_tool_details: {}", self.show_tool_details));
lines.push(format!(" locale: {}", self.locale));
lines.push(format!(" theme: {}", self.theme));
lines.push(format!(
" background_color: {}",
self.background_color.as_deref().unwrap_or("(default)")
));
lines.push(format!(" composer_density: {}", self.composer_density));
lines.push(format!(" composer_border: {}", self.composer_border));
lines.push(format!(" composer_vim_mode: {}", self.composer_vim_mode));
lines.push(format!(" transcript_spacing: {}", self.transcript_spacing));
lines.push(format!(" status_indicator: {}", self.status_indicator));
lines.push(format!(
" synchronized_output: {}",
self.synchronized_output
));
lines.push(format!(
" prefer_external_pdftotext: {}",
self.prefer_external_pdftotext
));
lines.push(format!(" default_mode: {}", self.default_mode));
lines.push(format!(
" sidebar_width: {}%",
self.sidebar_width_percent
));
lines.push(format!(" sidebar_focus: {}", self.sidebar_focus));
lines.push(format!(" context_panel: {}", self.context_panel));
lines.push(format!(" cost_currency: {}", self.cost_currency));
lines.push(format!(" max_history: {}", self.max_input_history));
lines.push(format!(
" default_model: {}",
self.default_model.as_deref().unwrap_or("(default)")
));
lines.push(format!(
" reasoning_effort: {}",
self.reasoning_effort
.as_deref()
.unwrap_or("(config/default)")
));
lines.push(String::new());
lines.push(format!(
"{} {}",
tr(locale, MessageId::SettingsConfigFile),
Self::path().map_or_else(|_| "(unknown)".to_string(), |p| p.display().to_string())
));
lines.join("\n")
}
#[allow(dead_code)]
pub fn available_settings() -> Vec<(&'static str, &'static str)> {
vec![
(
"auto_compact",
"Auto-compact near the hard context limit: on/off (default off)",
),
("calm_mode", "Calmer UI defaults: on/off"),
(
"low_motion",
"Streaming pacing: on = typewriter (one char/tick), off = upstream cadence",
),
(
"fancy_animations",
"Footer water-spout strip (wave synced to typing speed): on/off",
),
(
"bracketed_paste",
"Terminal bracketed-paste mode: on/off (rare to disable)",
),
(
"paste_burst_detection",
"Fallback rapid-key paste detection: on/off",
),
("show_thinking", "Show model thinking: on/off"),
("show_tool_details", "Show detailed tool output: on/off"),
(
"locale",
"UI locale and default model language: auto, en, ja, zh-Hans, pt-BR, es-419",
),
(
"theme",
"UI theme: system, dark, light, grayscale, catppuccin-mocha, tokyo-night, dracula, gruvbox-dark",
),
(
"background_color",
"Main TUI background color: #RRGGBB or default",
),
(
"composer_density",
"Composer density: compact, comfortable, spacious",
),
(
"composer_border",
"Show a border around the composer input area: on/off",
),
("composer_vim_mode", "Composer editing mode: normal, vim"),
(
"transcript_spacing",
"Transcript spacing: compact, comfortable, spacious",
),
(
"status_indicator",
"Header status indicator next to effort chip: whale, dots, off",
),
(
"synchronized_output",
"DEC 2026 synchronized output: auto, on, off (set off if your terminal flickers)",
),
(
"prefer_external_pdftotext",
"Route PDF reads through Poppler's pdftotext instead of the bundled pure-Rust extractor: on/off (default off)",
),
("default_mode", "Default mode: agent, plan, yolo"),
("sidebar_width", "Sidebar width percentage: 10-50"),
(
"sidebar_focus",
"Sidebar focus: auto, work, tasks, agents, context, hidden",
),
(
"context_panel",
"Show the session context sidebar panel: on/off",
),
("cost_currency", "Cost display currency: usd, cny"),
("max_history", "Max input history entries"),
(
"default_model",
"Default model: auto or any DeepSeek model ID (e.g. deepseek-v4-pro)",
),
(
"reasoning_effort",
"Default thinking effort: auto, off, low, medium, high, max, or default",
),
]
}
pub fn set_model_for_provider(&mut self, provider: &str, model: &str) {
self.provider_models
.get_or_insert_with(std::collections::HashMap::new)
.insert(provider.to_string(), model.to_string());
}
#[must_use]
pub fn synchronized_output_enabled(&self) -> bool {
!self.synchronized_output.eq_ignore_ascii_case("off")
}
}
fn normalize_default_model(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.eq_ignore_ascii_case("auto") {
Some("auto".to_string())
} else {
normalize_model_name(trimmed)
}
}
fn normalize_reasoning_effort_setting(value: &str) -> Result<Option<String>> {
let trimmed = value.trim();
if trimmed.is_empty()
|| matches!(
trimmed.to_ascii_lowercase().as_str(),
"default" | "(default)" | "config" | "configured" | "unset"
)
{
return Ok(None);
}
let normalized = match trimmed.to_ascii_lowercase().as_str() {
"off" | "disabled" | "none" | "false" => "off",
"low" | "minimal" => "low",
"medium" | "mid" => "medium",
"high" => "high",
"auto" | "automatic" => "auto",
"max" | "maximum" | "xhigh" => "max",
_ => {
anyhow::bail!(
"Failed to update setting: invalid reasoning_effort '{value}'. Expected: auto, off, low, medium, high, max, or default."
);
}
};
Ok(Some(normalized.to_string()))
}
fn parse_bool(value: &str) -> Result<bool> {
match value.to_lowercase().as_str() {
"on" | "true" | "yes" | "1" | "enabled" => Ok(true),
"off" | "false" | "no" | "0" | "disabled" => Ok(false),
_ => {
anyhow::bail!("Failed to parse boolean '{value}': expected on/off, true/false, yes/no.")
}
}
}
fn normalize_mode(value: &str) -> &str {
match value.trim().to_ascii_lowercase().as_str() {
"edit" => "agent",
"normal" => "agent",
"agent" => "agent",
"plan" => "plan",
"yolo" => "yolo",
_ => value,
}
}
fn normalize_composer_density(value: &str) -> &str {
match value.trim().to_ascii_lowercase().as_str() {
"compact" | "tight" => "compact",
"comfortable" | "default" | "normal" => "comfortable",
"spacious" | "loose" => "spacious",
_ => value,
}
}
fn normalize_transcript_spacing(value: &str) -> &str {
match value.trim().to_ascii_lowercase().as_str() {
"compact" | "tight" => "compact",
"comfortable" | "default" | "normal" => "comfortable",
"spacious" | "loose" => "spacious",
_ => value,
}
}
fn normalize_status_indicator(value: &str) -> &str {
match value.trim().to_ascii_lowercase().as_str() {
"whale" | "🐳" | "🐋" => "whale",
"dots" | "dot" => "dots",
"off" | "none" | "hidden" | "false" => "off",
_ => value,
}
}
fn normalize_synchronized_output(value: &str) -> &str {
match value.trim().to_ascii_lowercase().as_str() {
"auto" | "default" => "auto",
"on" | "true" | "yes" | "1" | "enabled" => "on",
"off" | "false" | "no" | "0" | "disabled" => "off",
_ => value,
}
}
fn normalize_settings_theme(value: &str) -> &'static str {
normalize_theme_name(value).unwrap_or("system")
}
pub fn detected_ptyxis_terminal() -> bool {
if let Ok(program) = std::env::var("TERM_PROGRAM")
&& program.trim().to_ascii_lowercase().contains("ptyxis")
{
return true;
}
matches!(std::env::var("PTYXIS_VERSION"), Ok(v) if !v.trim().is_empty())
}
pub fn detected_legacy_windows_console_host() -> bool {
cfg!(windows)
&& legacy_windows_console_host_env([
std::env::var_os("WT_SESSION").as_deref(),
std::env::var_os("ConEmuPID").as_deref(),
std::env::var_os("TERM_PROGRAM").as_deref(),
std::env::var_os("WEZTERM_EXECUTABLE").as_deref(),
std::env::var_os("WEZTERM_PANE").as_deref(),
std::env::var_os("ALACRITTY_WINDOW_ID").as_deref(),
std::env::var_os("ANSICON").as_deref(),
std::env::var_os("TERM").as_deref(),
])
}
fn legacy_windows_console_host_env(markers: [Option<&std::ffi::OsStr>; 8]) -> bool {
fn has_value(value: Option<&std::ffi::OsStr>) -> bool {
value.is_some_and(|v| !v.is_empty())
}
markers.into_iter().all(|value| !has_value(value))
}
fn normalize_optional_background_color(value: Option<&str>) -> Option<String> {
value.and_then(|raw| normalize_background_color_setting(raw).ok().flatten())
}
fn normalize_background_color_setting(value: &str) -> Result<Option<String>> {
let trimmed = value.trim();
if trimmed.is_empty()
|| matches!(
trimmed.to_ascii_lowercase().as_str(),
"default" | "none" | "reset" | "off"
)
{
return Ok(None);
}
normalize_hex_rgb_color(trimmed).map(Some).ok_or_else(|| {
anyhow::anyhow!(
"Failed to update setting: invalid background_color '{value}'. Expected #RRGGBB, RRGGBB, or default."
)
})
}
fn normalize_sidebar_focus(value: &str) -> &str {
match value.trim().to_ascii_lowercase().as_str() {
"work" | "plan" | "todos" => "work",
"tasks" => "tasks",
"agents" | "subagents" | "sub-agents" => "agents",
"context" | "session" => "context",
"hidden" | "hide" | "closed" | "off" | "none" => "hidden",
_ => "auto",
}
}
fn env_truthy(name: &str) -> bool {
match std::env::var(name) {
Ok(v) => matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
),
Err(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_settings_disable_auto_compact_to_protect_v4_prefix_cache() {
let settings = Settings::default();
assert!(!settings.auto_compact);
}
#[test]
fn auto_compact_remains_explicitly_configurable() {
let mut settings = Settings::default();
settings.set("auto_compact", "on").expect("enable");
assert!(settings.auto_compact);
settings.set("auto_compact", "off").expect("disable");
assert!(!settings.auto_compact);
}
#[test]
fn default_settings_show_footer_water_strip() {
let settings = Settings::default();
assert!(settings.fancy_animations);
}
#[test]
fn reasoning_effort_setting_normalizes_and_clears() {
let mut settings = Settings::default();
settings
.set("reasoning_effort", "xhigh")
.expect("normalize xhigh");
assert_eq!(settings.reasoning_effort.as_deref(), Some("max"));
settings
.set("reasoning_effort", "default")
.expect("clear effort");
assert!(settings.reasoning_effort.is_none());
}
#[test]
fn paste_burst_detection_is_configurable_independent_of_bracketed_paste() {
let mut settings = Settings::default();
assert!(settings.bracketed_paste);
assert!(settings.paste_burst_detection);
settings
.set("paste_burst_detection", "off")
.expect("disable paste burst fallback");
assert!(settings.bracketed_paste);
assert!(!settings.paste_burst_detection);
settings
.set("bracketed_paste", "off")
.expect("disable bracketed paste");
assert!(!settings.bracketed_paste);
assert!(!settings.paste_burst_detection);
}
#[test]
fn locale_normalizes_supported_values_and_rejects_unknowns() {
let mut settings = Settings::default();
settings.set("locale", "ja_JP.UTF-8").expect("set ja");
assert_eq!(settings.locale, "ja");
settings.set("language", "pt-PT").expect("set pt fallback");
assert_eq!(settings.locale, "pt-BR");
let err = settings
.set("locale", "ar")
.expect_err("Arabic is planned, not shipped");
assert!(err.to_string().contains("invalid locale"));
}
#[test]
fn theme_normalizes_supported_values_and_rejects_unknowns() {
let mut settings = Settings::default();
assert_eq!(settings.theme, "system");
settings.set("theme", "grayscale").expect("set grayscale");
assert_eq!(settings.theme, "grayscale");
settings.set("ui_theme", "black-white").expect("set alias");
assert_eq!(settings.theme, "grayscale");
settings.set("theme", "whale").expect("set dark alias");
assert_eq!(settings.theme, "dark");
settings
.set("theme", "tokyonight")
.expect("set community theme alias");
assert_eq!(settings.theme, "tokyo-night");
let err = settings
.set("theme", "solarized")
.expect_err("unknown theme should fail");
assert!(err.to_string().contains("invalid theme"));
}
#[test]
fn background_color_normalizes_hex_and_accepts_default() {
let mut settings = Settings::default();
settings
.set("background_color", "#1A1b26")
.expect("set custom background");
assert_eq!(settings.background_color.as_deref(), Some("#1a1b26"));
settings
.set("background", "default")
.expect("reset custom background");
assert_eq!(settings.background_color, None);
}
#[test]
fn background_color_rejects_invalid_hex() {
let mut settings = Settings::default();
let err = settings
.set("background_color", "#123")
.expect_err("short hex should fail");
assert!(err.to_string().contains("invalid background_color"));
}
#[test]
fn cost_currency_normalizes_yuan_aliases_and_rejects_unknowns() {
let mut settings = Settings::default();
assert_eq!(settings.cost_currency, "usd");
settings.set("cost_currency", "yuan").expect("set yuan");
assert_eq!(settings.cost_currency, "cny");
settings.set("currency", "rmb").expect("set rmb");
assert_eq!(settings.cost_currency, "cny");
let err = settings
.set("cost_currency", "eur")
.expect_err("unsupported currency");
assert!(err.to_string().contains("invalid cost currency"));
}
#[test]
fn sidebar_focus_accepts_work_values_and_legacy_aliases() {
let mut settings = Settings::default();
settings.set("sidebar_focus", "work").expect("set work");
assert_eq!(settings.sidebar_focus, "work");
settings.set("focus", "plan").expect("legacy plan alias");
assert_eq!(settings.sidebar_focus, "work");
settings.set("focus", "todos").expect("legacy todos alias");
assert_eq!(settings.sidebar_focus, "work");
settings.set("focus", "context").expect("context focus");
assert_eq!(settings.sidebar_focus, "context");
settings.set("focus", "hidden").expect("hidden focus");
assert_eq!(settings.sidebar_focus, "hidden");
settings.set("focus", "off").expect("off alias");
assert_eq!(settings.sidebar_focus, "hidden");
let err = settings
.set("sidebar_focus", "classic")
.expect_err("classic is not a supported public focus");
assert!(err.to_string().contains("invalid sidebar focus"));
}
#[test]
fn context_panel_is_configurable() {
let mut settings = Settings::default();
assert!(!settings.context_panel);
settings
.set("context_panel", "on")
.expect("enable context panel");
assert!(settings.context_panel);
settings
.set("session_panel", "off")
.expect("disable context panel via alias");
assert!(!settings.context_panel);
}
#[test]
fn display_localizes_header_and_config_file_label() {
let settings = Settings::default();
let en = settings.display(crate::localization::Locale::En);
assert!(en.contains("Settings:"), "english header missing:\n{en}");
assert!(
en.contains("Config file:"),
"english config label missing:\n{en}"
);
let zh = settings.display(crate::localization::Locale::ZhHans);
assert!(zh.contains("设置"), "chinese header missing:\n{zh}");
assert!(
zh.contains("配置文件"),
"chinese config label missing:\n{zh}"
);
}
fn no_animations_test_guard() -> std::sync::MutexGuard<'static, ()> {
crate::test_support::lock_test_env()
}
#[test]
fn no_animations_env_forces_low_motion_on() {
let _g = no_animations_test_guard();
unsafe {
std::env::set_var("NO_ANIMATIONS", "1");
}
let mut settings = Settings::default();
assert!(!settings.low_motion, "default is animated");
assert!(settings.fancy_animations, "default shows the water strip");
settings.apply_env_overrides();
assert!(settings.low_motion, "NO_ANIMATIONS=1 forces low_motion");
assert!(
!settings.fancy_animations,
"NO_ANIMATIONS=1 keeps fancy off"
);
unsafe {
std::env::remove_var("NO_ANIMATIONS");
}
}
#[test]
fn no_animations_env_overrides_user_opt_in() {
let _g = no_animations_test_guard();
unsafe {
std::env::set_var("NO_ANIMATIONS", "true");
}
let mut settings = Settings {
fancy_animations: true,
..Settings::default()
};
settings.apply_env_overrides();
assert!(
!settings.fancy_animations,
"platform NO_ANIMATIONS overrides user-opt-in fancy_animations"
);
assert!(settings.low_motion);
unsafe {
std::env::remove_var("NO_ANIMATIONS");
}
}
#[test]
fn no_animations_env_recognises_truthy_spellings_only() {
let _g = no_animations_test_guard();
let prev_wt_session = std::env::var_os("WT_SESSION");
let prev_tmux = std::env::var_os("TMUX");
let prev_sty = std::env::var_os("STY");
let prev_term_program = std::env::var_os("TERM_PROGRAM");
let prev_ssh_client = std::env::var_os("SSH_CLIENT");
let prev_ssh_tty = std::env::var_os("SSH_TTY");
let prev_tilix_id = std::env::var_os("TILIX_ID");
let prev_terminator_uuid = std::env::var_os("TERMINATOR_UUID");
unsafe {
std::env::remove_var("TMUX");
std::env::remove_var("STY");
std::env::remove_var("TERM_PROGRAM");
std::env::remove_var("SSH_CLIENT");
std::env::remove_var("SSH_TTY");
std::env::remove_var("TILIX_ID");
std::env::remove_var("TERMINATOR_UUID");
}
#[cfg(windows)]
unsafe {
std::env::set_var("WT_SESSION", "test");
}
for truthy in ["1", "true", "True", "YES", "on"] {
unsafe {
std::env::set_var("NO_ANIMATIONS", truthy);
}
let mut s = Settings::default();
s.apply_env_overrides();
assert!(s.low_motion, "{truthy:?} should be truthy");
}
for falsy in ["0", "false", "no", "off", ""] {
unsafe {
std::env::set_var("NO_ANIMATIONS", falsy);
}
let mut s = Settings::default();
s.apply_env_overrides();
assert!(!s.low_motion, "{falsy:?} should be falsy");
}
unsafe {
std::env::remove_var("NO_ANIMATIONS");
match prev_wt_session {
Some(v) => std::env::set_var("WT_SESSION", v),
None => std::env::remove_var("WT_SESSION"),
}
match prev_tmux {
Some(v) => std::env::set_var("TMUX", v),
None => std::env::remove_var("TMUX"),
}
match prev_sty {
Some(v) => std::env::set_var("STY", v),
None => std::env::remove_var("STY"),
}
match prev_term_program {
Some(v) => std::env::set_var("TERM_PROGRAM", v),
None => std::env::remove_var("TERM_PROGRAM"),
}
match prev_ssh_client {
Some(v) => std::env::set_var("SSH_CLIENT", v),
None => std::env::remove_var("SSH_CLIENT"),
}
match prev_ssh_tty {
Some(v) => std::env::set_var("SSH_TTY", v),
None => std::env::remove_var("SSH_TTY"),
}
match prev_tilix_id {
Some(v) => std::env::set_var("TILIX_ID", v),
None => std::env::remove_var("TILIX_ID"),
}
match prev_terminator_uuid {
Some(v) => std::env::set_var("TERMINATOR_UUID", v),
None => std::env::remove_var("TERMINATOR_UUID"),
}
}
}
fn term_program_test_guard() -> std::sync::MutexGuard<'static, ()> {
crate::test_support::lock_test_env()
}
#[test]
fn vscode_term_program_forces_low_motion_on() {
let _g = term_program_test_guard();
let prev = std::env::var_os("TERM_PROGRAM");
unsafe {
std::env::set_var("TERM_PROGRAM", "vscode");
}
let mut settings = Settings::default();
assert!(!settings.low_motion, "default is animated");
settings.apply_env_overrides();
assert!(
settings.low_motion,
"TERM_PROGRAM=vscode must enable low_motion to prevent flickering (#1356)"
);
assert!(
!settings.fancy_animations,
"TERM_PROGRAM=vscode must disable fancy_animations"
);
unsafe {
match prev {
Some(v) => std::env::set_var("TERM_PROGRAM", v),
None => std::env::remove_var("TERM_PROGRAM"),
}
}
}
#[test]
fn ghostty_term_program_forces_low_motion_on() {
let _g = term_program_test_guard();
let prev = std::env::var_os("TERM_PROGRAM");
unsafe {
std::env::set_var("TERM_PROGRAM", "ghostty");
}
let mut settings = Settings::default();
assert!(!settings.low_motion, "default is animated");
settings.apply_env_overrides();
assert!(
settings.low_motion,
"TERM_PROGRAM=ghostty must enable low_motion to prevent flickering (#1445)"
);
assert!(
!settings.fancy_animations,
"TERM_PROGRAM=ghostty must disable fancy_animations"
);
unsafe {
match prev {
Some(v) => std::env::set_var("TERM_PROGRAM", v),
None => std::env::remove_var("TERM_PROGRAM"),
}
}
}
#[test]
fn non_vscode_term_program_does_not_force_low_motion() {
let _g = term_program_test_guard();
let prev = std::env::var_os("TERM_PROGRAM");
let prev_ssh_client = std::env::var_os("SSH_CLIENT");
let prev_ssh_tty = std::env::var_os("SSH_TTY");
let prev_tilix_id = std::env::var_os("TILIX_ID");
let prev_terminator_uuid = std::env::var_os("TERMINATOR_UUID");
let prev_tmux = std::env::var_os("TMUX");
let prev_sty = std::env::var_os("STY");
unsafe {
std::env::remove_var("SSH_CLIENT");
std::env::remove_var("SSH_TTY");
std::env::remove_var("TILIX_ID");
std::env::remove_var("TERMINATOR_UUID");
std::env::remove_var("TMUX");
std::env::remove_var("STY");
}
for program in ["iTerm.app", "Apple_Terminal", "WezTerm", "xterm-256color"] {
unsafe {
std::env::set_var("TERM_PROGRAM", program);
}
let mut s = Settings::default();
s.apply_env_overrides();
assert!(
!s.low_motion,
"TERM_PROGRAM={program:?} should not force low_motion"
);
}
unsafe {
match prev {
Some(v) => std::env::set_var("TERM_PROGRAM", v),
None => std::env::remove_var("TERM_PROGRAM"),
}
if let Some(v) = prev_ssh_client {
std::env::set_var("SSH_CLIENT", v);
}
if let Some(v) = prev_ssh_tty {
std::env::set_var("SSH_TTY", v);
}
if let Some(v) = prev_tilix_id {
std::env::set_var("TILIX_ID", v);
}
if let Some(v) = prev_terminator_uuid {
std::env::set_var("TERMINATOR_UUID", v);
}
if let Some(v) = prev_tmux {
std::env::set_var("TMUX", v);
}
if let Some(v) = prev_sty {
std::env::set_var("STY", v);
}
}
}
#[test]
fn tilix_and_terminator_env_force_low_motion_on() {
let _g = term_program_test_guard();
let prev_term_program = std::env::var_os("TERM_PROGRAM");
let prev_tilix_id = std::env::var_os("TILIX_ID");
let prev_terminator_uuid = std::env::var_os("TERMINATOR_UUID");
for (var, val) in [
("TILIX_ID", "d5b5b5d6-tilix-session"),
("TERMINATOR_UUID", "urn:uuid:terminator-session"),
] {
unsafe {
std::env::remove_var("TERM_PROGRAM");
std::env::remove_var("TILIX_ID");
std::env::remove_var("TERMINATOR_UUID");
std::env::set_var(var, val);
}
let mut settings = Settings::default();
assert!(!settings.low_motion, "default is animated");
settings.apply_env_overrides();
assert!(
settings.low_motion,
"{var} must enable low_motion to prevent VTE flicker (#1470)"
);
assert!(
!settings.fancy_animations,
"{var} must disable fancy_animations"
);
}
unsafe {
match prev_term_program {
Some(v) => std::env::set_var("TERM_PROGRAM", v),
None => std::env::remove_var("TERM_PROGRAM"),
}
match prev_tilix_id {
Some(v) => std::env::set_var("TILIX_ID", v),
None => std::env::remove_var("TILIX_ID"),
}
match prev_terminator_uuid {
Some(v) => std::env::set_var("TERMINATOR_UUID", v),
None => std::env::remove_var("TERMINATOR_UUID"),
}
}
}
#[test]
fn termius_term_program_forces_low_motion_on() {
let _g = term_program_test_guard();
let prev = std::env::var_os("TERM_PROGRAM");
unsafe {
std::env::set_var("TERM_PROGRAM", "Termius");
}
let mut settings = Settings::default();
assert!(!settings.low_motion, "default is animated");
settings.apply_env_overrides();
assert!(
settings.low_motion,
"TERM_PROGRAM=Termius must enable low_motion to prevent flickering (#1433)"
);
assert!(
!settings.fancy_animations,
"TERM_PROGRAM=Termius must disable fancy_animations"
);
unsafe {
match prev {
Some(v) => std::env::set_var("TERM_PROGRAM", v),
None => std::env::remove_var("TERM_PROGRAM"),
}
}
}
#[test]
fn legacy_windows_console_host_detects_unmarked_shell() {
assert!(legacy_windows_console_host_env([
None, None, None, None, None, None, None, None
]));
}
#[test]
fn legacy_windows_console_host_excludes_modern_terminal_markers() {
use std::ffi::OsStr;
let marker = Some(OsStr::new("1"));
assert!(!legacy_windows_console_host_env([
marker, None, None, None, None, None, None, None
]));
assert!(!legacy_windows_console_host_env([
None, marker, None, None, None, None, None, None
]));
assert!(!legacy_windows_console_host_env([
None, None, marker, None, None, None, None, None
]));
assert!(!legacy_windows_console_host_env([
None, None, None, marker, None, None, None, None
]));
assert!(!legacy_windows_console_host_env([
None, None, None, None, marker, None, None, None
]));
assert!(!legacy_windows_console_host_env([
None, None, None, None, None, marker, None, None
]));
assert!(!legacy_windows_console_host_env([
None, None, None, None, None, None, marker, None
]));
assert!(!legacy_windows_console_host_env([
None, None, None, None, None, None, None, marker
]));
}
#[cfg(windows)]
#[test]
fn unmarked_windows_console_forces_calm_rendering() {
let _g = term_program_test_guard();
let vars = [
"WT_SESSION",
"ConEmuPID",
"TERM_PROGRAM",
"WEZTERM_EXECUTABLE",
"WEZTERM_PANE",
"ALACRITTY_WINDOW_ID",
"ANSICON",
"TERM",
"SSH_CLIENT",
"SSH_TTY",
"NO_ANIMATIONS",
"PTYXIS_VERSION",
];
let prev: Vec<_> = vars
.iter()
.map(|name| (*name, std::env::var_os(name)))
.collect();
unsafe {
for name in vars {
std::env::remove_var(name);
}
}
let mut settings = Settings::default();
assert!(!settings.low_motion, "default is animated");
assert!(settings.fancy_animations, "default shows the water strip");
assert_eq!(settings.synchronized_output, "auto");
settings.apply_env_overrides();
assert!(settings.low_motion);
assert!(!settings.fancy_animations);
assert_eq!(settings.synchronized_output, "off");
unsafe {
for (name, value) in prev {
match value {
Some(value) => std::env::set_var(name, value),
None => std::env::remove_var(name),
}
}
}
}
#[test]
fn ssh_session_forces_low_motion_on() {
let _g = term_program_test_guard();
let prev_client = std::env::var_os("SSH_CLIENT");
let prev_tty = std::env::var_os("SSH_TTY");
let prev_term_program = std::env::var_os("TERM_PROGRAM");
for (var, val) in [
("SSH_CLIENT", "192.168.1.100 50000 22"),
("SSH_TTY", "/dev/pts/0"),
] {
unsafe {
std::env::remove_var("SSH_CLIENT");
std::env::remove_var("SSH_TTY");
std::env::remove_var("TERM_PROGRAM");
std::env::set_var(var, val);
}
let mut s = Settings::default();
s.apply_env_overrides();
assert!(
s.low_motion,
"{var}={val:?} must enable low_motion to prevent flickering in SSH sessions (#1433)"
);
assert!(
!s.fancy_animations,
"{var}={val:?} must disable fancy_animations in SSH sessions (#1433)"
);
}
unsafe {
std::env::remove_var("SSH_CLIENT");
std::env::remove_var("SSH_TTY");
if let Some(v) = prev_client {
std::env::set_var("SSH_CLIENT", v);
}
if let Some(v) = prev_tty {
std::env::set_var("SSH_TTY", v);
}
match prev_term_program {
Some(v) => std::env::set_var("TERM_PROGRAM", v),
None => std::env::remove_var("TERM_PROGRAM"),
}
}
}
#[test]
fn terminal_multiplexer_env_forces_low_motion_on() {
let _g = term_program_test_guard();
let vars = [
"TMUX",
"STY",
"TERM_PROGRAM",
"SSH_CLIENT",
"SSH_TTY",
"TILIX_ID",
"TERMINATOR_UUID",
"NO_ANIMATIONS",
];
let prev: Vec<_> = vars
.iter()
.map(|name| (*name, std::env::var_os(name)))
.collect();
for (var, val) in [
("TMUX", "/tmp/tmux-501/default,1234,0"),
("STY", "1234.pts-0.host"),
] {
unsafe {
for name in vars {
std::env::remove_var(name);
}
std::env::set_var(var, val);
}
let mut settings = Settings::default();
assert!(!settings.low_motion, "default is animated");
assert!(settings.fancy_animations, "default shows the water strip");
settings.apply_env_overrides();
assert!(
settings.low_motion,
"{var}={val:?} must enable low_motion under terminal multiplexers (#1925)"
);
assert!(
!settings.fancy_animations,
"{var}={val:?} must disable fancy_animations under terminal multiplexers (#1925)"
);
}
unsafe {
for (name, value) in prev {
match value {
Some(value) => std::env::set_var(name, value),
None => std::env::remove_var(name),
}
}
}
}
#[test]
fn synchronized_output_defaults_to_auto_and_resolves_to_enabled() {
let s = Settings::default();
assert_eq!(s.synchronized_output, "auto");
assert!(
s.synchronized_output_enabled(),
"auto must keep DEC 2026 on so terminals that support it stay tear-free"
);
}
#[test]
fn synchronized_output_off_disables_dec_2026() {
let s = Settings {
synchronized_output: "off".to_string(),
..Settings::default()
};
assert!(!s.synchronized_output_enabled());
}
#[test]
fn synchronized_output_on_keeps_dec_2026_enabled() {
let s = Settings {
synchronized_output: "on".to_string(),
..Settings::default()
};
assert!(s.synchronized_output_enabled());
}
#[test]
fn synchronized_output_set_command_accepts_aliases() {
let mut s = Settings::default();
for value in ["auto", "AUTO", "default"] {
s.set("synchronized_output", value).expect("valid");
assert_eq!(s.synchronized_output, "auto");
}
for value in ["on", "true", "yes", "1", "ENABLED"] {
s.set("sync_output", value).expect("valid");
assert_eq!(s.synchronized_output, "on");
}
for value in ["off", "false", "no", "0", "DISABLED"] {
s.set("sync", value).expect("valid");
assert_eq!(s.synchronized_output, "off");
}
let err = s
.set("synchronized_output", "maybe")
.expect_err("unknown value rejected");
assert!(
err.to_string().contains("synchronized_output"),
"error names the offending key: {err}"
);
}
#[test]
fn ptyxis_term_program_flips_synchronized_output_off() {
let _g = term_program_test_guard();
let prev = std::env::var_os("TERM_PROGRAM");
let prev_ptyxis = std::env::var_os("PTYXIS_VERSION");
unsafe {
std::env::set_var("TERM_PROGRAM", "Ptyxis");
std::env::remove_var("PTYXIS_VERSION");
}
let mut s = Settings::default();
assert_eq!(s.synchronized_output, "auto");
s.apply_env_overrides();
assert_eq!(
s.synchronized_output, "off",
"Ptyxis 50.x mishandles DEC 2026 — auto must flip to off so VTE 0.84 stops flickering"
);
assert!(
!s.synchronized_output_enabled(),
"resolved boolean must agree with stored string"
);
unsafe {
match prev {
Some(v) => std::env::set_var("TERM_PROGRAM", v),
None => std::env::remove_var("TERM_PROGRAM"),
}
match prev_ptyxis {
Some(v) => std::env::set_var("PTYXIS_VERSION", v),
None => std::env::remove_var("PTYXIS_VERSION"),
}
}
}
#[test]
fn ptyxis_version_env_alone_flips_synchronized_output_off() {
let _g = term_program_test_guard();
let prev = std::env::var_os("TERM_PROGRAM");
let prev_ptyxis = std::env::var_os("PTYXIS_VERSION");
unsafe {
std::env::remove_var("TERM_PROGRAM");
std::env::set_var("PTYXIS_VERSION", "50.1");
}
let mut s = Settings::default();
s.apply_env_overrides();
assert_eq!(
s.synchronized_output, "off",
"PTYXIS_VERSION alone is sufficient — Ptyxis sets this even when TERM_PROGRAM isn't propagated"
);
unsafe {
match prev {
Some(v) => std::env::set_var("TERM_PROGRAM", v),
None => std::env::remove_var("TERM_PROGRAM"),
}
match prev_ptyxis {
Some(v) => std::env::set_var("PTYXIS_VERSION", v),
None => std::env::remove_var("PTYXIS_VERSION"),
}
}
}
#[test]
fn ptyxis_does_not_override_user_explicit_on() {
let _g = term_program_test_guard();
let prev = std::env::var_os("TERM_PROGRAM");
unsafe {
std::env::set_var("TERM_PROGRAM", "ptyxis");
}
let mut s = Settings {
synchronized_output: "on".to_string(),
..Settings::default()
};
s.apply_env_overrides();
assert_eq!(
s.synchronized_output, "on",
"explicit user override must beat the Ptyxis env heuristic"
);
unsafe {
match prev {
Some(v) => std::env::set_var("TERM_PROGRAM", v),
None => std::env::remove_var("TERM_PROGRAM"),
}
}
}
#[test]
fn ptyxis_does_not_override_user_explicit_off() {
let _g = term_program_test_guard();
let prev = std::env::var_os("TERM_PROGRAM");
unsafe {
std::env::set_var("TERM_PROGRAM", "xterm-256color");
}
let mut s = Settings {
synchronized_output: "off".to_string(),
..Settings::default()
};
s.apply_env_overrides();
assert_eq!(s.synchronized_output, "off");
unsafe {
match prev {
Some(v) => std::env::set_var("TERM_PROGRAM", v),
None => std::env::remove_var("TERM_PROGRAM"),
}
}
}
#[test]
fn non_ptyxis_term_programs_keep_synchronized_output_auto() {
let _g = term_program_test_guard();
let prev = std::env::var_os("TERM_PROGRAM");
let prev_ptyxis = std::env::var_os("PTYXIS_VERSION");
unsafe {
std::env::remove_var("PTYXIS_VERSION");
}
for program in [
"iTerm.app",
"Apple_Terminal",
"WezTerm",
"xterm-256color",
"gnome-terminal-server",
"ghostty",
"vscode",
] {
unsafe {
std::env::set_var("TERM_PROGRAM", program);
}
let mut s = Settings::default();
s.apply_env_overrides();
assert_eq!(
s.synchronized_output, "auto",
"TERM_PROGRAM={program:?} must not opt out of DEC 2026"
);
assert!(
s.synchronized_output_enabled(),
"resolved boolean for {program:?} must stay enabled"
);
}
unsafe {
match prev {
Some(v) => std::env::set_var("TERM_PROGRAM", v),
None => std::env::remove_var("TERM_PROGRAM"),
}
match prev_ptyxis {
Some(v) => std::env::set_var("PTYXIS_VERSION", v),
None => std::env::remove_var("PTYXIS_VERSION"),
}
}
}
fn config_path_test_guard() -> std::sync::MutexGuard<'static, ()> {
crate::test_support::lock_test_env()
}
#[test]
fn tui_prefs_defaults_are_dark_theme_zero_font() {
let prefs = TuiPrefs::default();
assert_eq!(prefs.theme, "dark");
assert_eq!(prefs.font_size, 0);
assert!(prefs.keybinds.submit.is_none());
assert!(prefs.keybinds.new_line.is_none());
}
#[test]
fn tui_prefs_validate_accepts_known_themes() {
for theme in [
"dark",
"light",
"system",
"grayscale",
"catppuccin-mocha",
"tokyo-night",
"dracula",
"gruvbox-dark",
] {
let mut prefs = TuiPrefs {
theme: theme.to_string(),
..TuiPrefs::default()
};
prefs
.validate()
.unwrap_or_else(|e| panic!("validate({theme}) failed: {e}"));
assert_eq!(prefs.theme, theme);
}
}
#[test]
fn tui_prefs_validate_normalises_theme_case() {
let mut prefs = TuiPrefs {
theme: "MONO".to_string(),
..TuiPrefs::default()
};
prefs
.validate()
.expect("MONO should normalise to grayscale");
assert_eq!(prefs.theme, "grayscale");
}
#[test]
fn tui_prefs_validate_rejects_unknown_theme() {
let mut prefs = TuiPrefs {
theme: "solarized".to_string(),
..TuiPrefs::default()
};
let err = prefs
.validate()
.expect_err("solarized is not a valid theme");
assert!(err.to_string().contains("Invalid tui.toml theme"));
assert!(
err.to_string()
.contains("expected system, dark, light, grayscale")
);
}
#[test]
fn tui_prefs_round_trips_through_toml() {
let prefs = TuiPrefs {
theme: "light".to_string(),
font_size: 16,
keybinds: KeybindPrefs {
submit: Some("ctrl+enter".to_string()),
new_line: Some("enter".to_string()),
command_palette: None,
cancel: None,
toggle_sidebar: None,
},
};
let serialised = toml::to_string_pretty(&prefs).expect("serialise");
let de: TuiPrefs = toml::from_str(&serialised).expect("deserialise");
assert_eq!(de.theme, "light");
assert_eq!(de.font_size, 16);
assert_eq!(de.keybinds.submit.as_deref(), Some("ctrl+enter"));
assert_eq!(de.keybinds.new_line.as_deref(), Some("enter"));
assert!(de.keybinds.command_palette.is_none());
}
#[test]
fn tui_prefs_load_returns_defaults_when_file_absent() {
let _g = config_path_test_guard();
let tmp = std::env::temp_dir().join("dst_tui_prefs_absent_test");
std::fs::create_dir_all(&tmp).unwrap();
unsafe {
std::env::set_var(
"DEEPSEEK_CONFIG_PATH",
tmp.join("config.toml").to_str().unwrap(),
);
}
let prefs = TuiPrefs::load().expect("load should not fail when file absent");
assert_eq!(prefs.theme, "dark", "should fall back to default theme");
unsafe {
std::env::remove_var("DEEPSEEK_CONFIG_PATH");
}
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn tui_prefs_save_and_load_round_trip() {
let _g = config_path_test_guard();
let tmp = std::env::temp_dir().join("dst_tui_prefs_save_test");
std::fs::create_dir_all(&tmp).unwrap();
unsafe {
std::env::set_var(
"DEEPSEEK_CONFIG_PATH",
tmp.join("config.toml").to_str().unwrap(),
);
}
let prefs = TuiPrefs {
theme: "light".to_string(),
font_size: 14,
keybinds: KeybindPrefs {
submit: Some("ctrl+enter".to_string()),
..KeybindPrefs::default()
},
};
prefs.save().expect("save should succeed");
let loaded = TuiPrefs::load().expect("load after save");
assert_eq!(loaded.theme, "light");
assert_eq!(loaded.font_size, 14);
assert_eq!(loaded.keybinds.submit.as_deref(), Some("ctrl+enter"));
unsafe {
std::env::remove_var("DEEPSEEK_CONFIG_PATH");
}
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn tui_prefs_path_uses_home_deepseek_subdir_by_default() {
let _g = config_path_test_guard();
if let Some(home) = dirs::home_dir() {
let expected = home.join(".deepseek").join("tui.toml");
if std::env::var("DEEPSEEK_CONFIG_PATH").is_err() {
let got = TuiPrefs::path().expect("path should resolve");
assert_eq!(got, expected);
}
}
}
}