fix(config): persist cost currency from /config

Summary:
- Let /config cost_currency cny --save persist the setting.
- Show the live cost currency via /config cost_currency.
- Add cost currency to the interactive config document and apply path.
- Add regression tests for command persistence and config UI currency round-tripping.

Test plan:
- cargo test -p deepseek-tui config_command_cost_currency_save_persists_value --locked
- cargo test -p deepseek-tui build_document_reflects_cost_currency_from_settings --locked
- cargo test -p deepseek-tui session_only_apply_keeps_runtime_overrides_and_skips_reload --locked
- cargo fmt --all -- --check
- git diff --check

Supersedes #956. Fixes #932.
This commit is contained in:
Hunter Bown
2026-05-07 03:46:02 -05:00
committed by GitHub
parent 063d5d7d99
commit d2e9f58756
2 changed files with 122 additions and 3 deletions
+47 -3
View File
@@ -44,7 +44,7 @@ pub fn show_config(_app: &mut App, arg: Option<&str>) -> CommandResult {
/// - `/config tui` / `/config web` / `/config native` — open a specific
/// editor mode (web requires the `web` build feature).
/// - `/config <key>` — shows the current value of a setting.
/// - `/config <key> <value>` — sets a runtime value (session only, no --save).
/// - `/config <key> <value>` — sets a runtime value (session only, add --save to persist).
pub fn config_command(app: &mut App, arg: Option<&str>) -> CommandResult {
let raw = arg.map(str::trim).unwrap_or("");
if raw.is_empty() {
@@ -63,8 +63,18 @@ pub fn config_command(app: &mut App, arg: Option<&str>) -> CommandResult {
// `/config <key>` — show current value
show_single_setting(app, token)
} else {
// `/config <key> <value>` — set value
set_config_value(app, parts[0], parts[1], false)
// `/config <key> <value> [--save|-s]` — set value, optionally persist
let raw_value = parts[1];
let persist = raw_value.ends_with(" --save") || raw_value.ends_with(" -s");
let value = if persist {
raw_value
.strip_suffix(" --save")
.or_else(|| raw_value.strip_suffix(" -s"))
.unwrap_or(raw_value)
} else {
raw_value
};
set_config_value(app, parts[0], value, persist)
}
}
@@ -127,6 +137,13 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult {
"transcript_spacing" | "spacing" => {
Some(spacing_display(app.transcript_spacing).to_string())
}
"cost_currency" | "currency" => Some(
match app.cost_currency {
crate::pricing::CostCurrency::Usd => "usd",
crate::pricing::CostCurrency::Cny => "cny",
}
.to_string(),
),
_ => {
let known = Settings::available_settings()
.iter()
@@ -1216,6 +1233,33 @@ mod tests {
assert!(saved.contains("default_mode = \"agent\""));
}
#[test]
fn config_command_cost_currency_save_persists_value() {
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"deepseek-tui-cost-currency-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
let mut app = create_test_app();
let result = config_command(&mut app, Some("cost_currency cny --save"));
let msg = result.message.unwrap();
assert_eq!(msg, "cost_currency = cny (saved)");
assert_eq!(app.cost_currency, crate::pricing::CostCurrency::Cny);
let settings_path = Settings::path().unwrap();
let saved = fs::read_to_string(settings_path).unwrap();
assert!(saved.contains("cost_currency = \"cny\""));
}
#[test]
fn test_set_approval_mode_valid_values() {
let mut app = create_test_app();
+75
View File
@@ -67,6 +67,7 @@ pub struct SettingsSection {
pub sidebar_focus: SidebarFocusValue,
#[schemars(range(min = 0))]
pub max_history: usize,
pub cost_currency: CostCurrencyValue,
pub default_model: Option<String>,
}
@@ -181,6 +182,13 @@ pub enum DefaultModeValue {
Yolo,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CostCurrencyValue {
Usd,
Cny,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SidebarFocusValue {
@@ -267,6 +275,7 @@ pub fn build_document(app: &App, config: &Config) -> Result<ConfigUiDocument> {
sidebar_width: settings.sidebar_width_percent,
sidebar_focus: settings.sidebar_focus.as_str().into(),
max_history: settings.max_input_history,
cost_currency: CostCurrencyValue::from_setting(&settings.cost_currency)?,
default_model,
},
config: ConfigSection {
@@ -428,6 +437,7 @@ pub fn apply_document(
("sidebar_width", &doc.settings.sidebar_width.to_string()),
("sidebar_focus", doc.settings.sidebar_focus.as_setting()),
("max_history", &doc.settings.max_history.to_string()),
("cost_currency", doc.settings.cost_currency.as_setting()),
("mcp_config_path", doc.config.mcp_config_path.as_str()),
] {
let result = commands::set_config_value(app, key, value, persist);
@@ -664,6 +674,25 @@ impl DefaultModeValue {
}
}
impl CostCurrencyValue {
fn from_setting(value: &str) -> Result<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"usd" => Ok(Self::Usd),
"cny" | "rmb" | "yuan" => Ok(Self::Cny),
other => {
anyhow::bail!("Invalid cost_currency '{other}': expected usd, cny, rmb, or yuan")
}
}
}
fn as_setting(self) -> &'static str {
match self {
Self::Usd => "usd",
Self::Cny => "cny",
}
}
}
impl SidebarFocusValue {
fn as_setting(self) -> &'static str {
match self {
@@ -859,6 +888,50 @@ mod tests {
assert_eq!(doc.config.reasoning_effort, ReasoningEffortValue::Max);
}
#[test]
fn build_document_reflects_cost_currency_from_settings() {
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock")
.as_nanos();
let temp_root = std::env::temp_dir().join(format!(
"deepseek-config-ui-cost-currency-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(temp_root.join(".deepseek")).expect("config dir");
let config_path = temp_root.join(".deepseek").join("config.toml");
fs::write(&config_path, "").expect("seed config");
fs::write(
temp_root.join(".deepseek").join("settings.toml"),
r#"
cost_currency = "cny"
"#,
)
.expect("seed settings");
let old_config_path = std::env::var_os("DEEPSEEK_CONFIG_PATH");
// Safety: test-only environment mutation guarded by a module mutex.
unsafe {
std::env::set_var("DEEPSEEK_CONFIG_PATH", &config_path);
}
let app = app();
let config = Config::default();
let doc = build_document(&app, &config).expect("document");
assert_eq!(doc.settings.cost_currency, CostCurrencyValue::Cny);
// Safety: restore the guarded test-only environment mutation above.
unsafe {
if let Some(value) = old_config_path {
std::env::set_var("DEEPSEEK_CONFIG_PATH", value);
} else {
std::env::remove_var("DEEPSEEK_CONFIG_PATH");
}
}
}
#[test]
fn schema_contains_typed_enums() {
let schema = build_schema();
@@ -920,6 +993,7 @@ mcp_config_path = "disk-mcp.json"
doc.runtime.model = "deepseek-v4-flash".to_string();
doc.config.reasoning_effort = ReasoningEffortValue::Low;
doc.config.mcp_config_path = "session-mcp.json".to_string();
doc.settings.cost_currency = CostCurrencyValue::Cny;
let outcome = apply_document(doc, &mut app, &mut config, false).expect("apply");
@@ -928,6 +1002,7 @@ mcp_config_path = "disk-mcp.json"
assert_eq!(app.model, "deepseek-v4-flash");
assert_eq!(app.reasoning_effort, ReasoningEffort::Low);
assert_eq!(app.mcp_config_path, PathBuf::from("session-mcp.json"));
assert_eq!(app.cost_currency, crate::pricing::CostCurrency::Cny);
assert_eq!(
config.reasoning_effort.as_deref(),
Some(ReasoningEffort::Low.as_setting())