use std::path::PathBuf;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::config::{expand_path, normalize_model_name};
#[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 show_thinking: bool,
pub show_tool_details: bool,
pub composer_density: String,
pub composer_border: bool,
pub transcript_spacing: String,
pub default_mode: String,
pub sidebar_width_percent: u16,
pub sidebar_focus: String,
pub max_input_history: usize,
pub default_model: Option<String>,
}
impl Default for Settings {
fn default() -> Self {
Self {
auto_compact: true,
calm_mode: false,
low_motion: false,
fancy_animations: false,
bracketed_paste: true,
show_thinking: true,
show_tool_details: true,
composer_density: "comfortable".to_string(),
composer_border: true,
transcript_spacing: "comfortable".to_string(),
default_mode: "agent".to_string(),
sidebar_width_percent: 28,
sidebar_focus: "auto".to_string(),
max_input_history: 100,
default_model: None,
}
}
}
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()?;
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read settings from {}", path.display()))?;
let mut settings: Settings = toml::from_str(&content)
.with_context(|| format!("Failed to parse settings from {}", path.display()))?;
settings.default_mode = normalize_mode(&settings.default_mode).to_string();
settings.composer_density =
normalize_composer_density(&settings.composer_density).to_string();
settings.transcript_spacing =
normalize_transcript_spacing(&settings.transcript_spacing).to_string();
settings.sidebar_focus = normalize_sidebar_focus(&settings.sidebar_focus).to_string();
settings.default_model = settings
.default_model
.as_deref()
.and_then(normalize_model_name);
Ok(settings)
}
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)?;
}
"show_thinking" | "thinking" => {
self.show_thinking = parse_bool(value)?;
}
"show_tool_details" | "tool_details" => {
self.show_tool_details = parse_bool(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)?;
}
"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();
}
"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",
"plan" => "plan",
"todos" => "todos",
"tasks" => "tasks",
"agents" | "subagents" | "sub-agents" => "agents",
_ => {
anyhow::bail!(
"Failed to update setting: invalid sidebar focus '{value}'. Expected: auto, plan, todos, tasks, agents."
)
}
};
self.sidebar_focus = normalized.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_model_name(trimmed) else {
anyhow::bail!(
"Failed to update setting: invalid model '{value}'. Expected: a DeepSeek model ID (for example deepseek-v4-pro, deepseek-v4-flash), or none/default."
);
};
self.default_model = Some(model);
}
_ => {
anyhow::bail!("Failed to update setting: unknown setting '{key}'.");
}
}
Ok(())
}
pub fn display(&self) -> String {
let mut lines = Vec::new();
lines.push("Settings:".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!(" show_thinking: {}", self.show_thinking));
lines.push(format!(" show_tool_details: {}", self.show_tool_details));
lines.push(format!(" composer_density: {}", self.composer_density));
lines.push(format!(" composer_border: {}", self.composer_border));
lines.push(format!(" transcript_spacing: {}", self.transcript_spacing));
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!(" max_history: {}", self.max_input_history));
lines.push(format!(
" default_model: {}",
self.default_model.as_deref().unwrap_or("(default)")
));
lines.push(String::new());
lines.push(format!(
"Config file: {}",
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 conversations: on/off"),
("calm_mode", "Calmer UI defaults: on/off"),
("low_motion", "Reduce animation and redraw churn: on/off"),
(
"fancy_animations",
"Fancy footer animations (water-spout strip): on/off",
),
(
"bracketed_paste",
"Terminal bracketed-paste mode: on/off (rare to disable)",
),
("show_thinking", "Show model thinking: on/off"),
("show_tool_details", "Show detailed tool output: on/off"),
(
"composer_density",
"Composer density: compact, comfortable, spacious",
),
(
"composer_border",
"Show a border around the composer input area: on/off",
),
(
"transcript_spacing",
"Transcript spacing: compact, comfortable, spacious",
),
("default_mode", "Default mode: agent, plan, yolo"),
("sidebar_width", "Sidebar width percentage: 10-50"),
(
"sidebar_focus",
"Sidebar focus: auto, plan, todos, tasks, agents",
),
("max_history", "Max input history entries"),
(
"default_model",
"Default model: any DeepSeek model ID (e.g. deepseek-v4-pro)",
),
]
}
}
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_sidebar_focus(value: &str) -> &str {
match value.trim().to_ascii_lowercase().as_str() {
"plan" => "plan",
"todos" => "todos",
"tasks" => "tasks",
"agents" | "subagents" | "sub-agents" => "agents",
_ => "auto",
}
}