diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 4ce80cf1..7d70992f 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -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 ` — shows the current value of a setting. -/// - `/config ` — sets a runtime value (session only, no --save). +/// - `/config ` — 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 ` — show current value show_single_setting(app, token) } else { - // `/config ` — set value - set_config_value(app, parts[0], parts[1], false) + // `/config [--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(); diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index b9d84913..acc90d67 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -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, } @@ -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 { 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 { + 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())