diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index c84afec16..caac223af 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -5,7 +5,9 @@ use std::time::Duration; use super::CommandResult; use crate::client::DeepSeekClient; -use crate::config::{COMMON_DEEPSEEK_MODELS, clear_api_key, normalize_model_name_for_provider}; +use crate::config::{ + COMMON_DEEPSEEK_MODELS, Config, clear_api_key, expand_path, normalize_model_name_for_provider, +}; use crate::config_ui::{ConfigUiMode, parse_mode}; use crate::llm_client::LlmClient; use crate::localization::resolve_locale; @@ -122,6 +124,16 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { } } "approval_mode" | "approval" => Some(app.approval_mode.label().to_string()), + "base_url" => { + let config = match Config::load(app.config_path.clone(), app.config_profile.as_deref()) + { + Ok(config) => config, + Err(err) => { + return CommandResult::error(format!("Failed to load config: {err}")); + } + }; + Some(config.deepseek_base_url()) + } "locale" | "language" => Some(locale_display(app.ui_locale).to_string()), "theme" | "ui_theme" => { Some(crate::palette::theme_label_for_mode(app.ui_theme.mode).to_string()) @@ -284,7 +296,7 @@ pub fn persist_status_items(items: &[crate::config::StatusItem]) -> anyhow::Resu use anyhow::Context; use std::fs; - let path = config_toml_path()?; + let path = config_toml_path(None)?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create config directory {}", parent.display()))?; @@ -320,11 +332,15 @@ pub fn persist_status_items(items: &[crate::config::StatusItem]) -> anyhow::Resu Ok(path) } -pub fn persist_root_string_key(key: &str, value: &str) -> anyhow::Result { +pub fn persist_root_string_key( + config_path: Option<&Path>, + key: &str, + value: &str, +) -> anyhow::Result { use anyhow::Context; use std::fs; - let path = config_toml_path()?; + let path = config_toml_path(config_path)?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create config directory {}", parent.display()))?; @@ -351,8 +367,11 @@ pub fn persist_root_string_key(key: &str, value: &str) -> anyhow::Result anyhow::Result { +pub(super) fn config_toml_path(config_path: Option<&Path>) -> anyhow::Result { use anyhow::Context; + if let Some(path) = config_path { + return Ok(expand_path(path.to_string_lossy().as_ref())); + } if let Ok(env) = std::env::var("DEEPSEEK_CONFIG_PATH") { let trimmed = env.trim(); if !trimmed.is_empty() { @@ -417,7 +436,8 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> app.mcp_config_path = PathBuf::from(expand_tilde(value)); app.mcp_restart_required = true; let message = if persist { - match persist_root_string_key("mcp_config_path", value) { + match persist_root_string_key(app.config_path.as_deref(), "mcp_config_path", value) + { Ok(path) => format!( "mcp_config_path = {} (saved to {}; restart required for MCP tool pool)", app.mcp_config_path.display(), @@ -433,6 +453,26 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> }; return CommandResult::message(message); } + "base_url" => { + let value = value.trim(); + if value.is_empty() { + return CommandResult::error("base_url cannot be empty"); + } + if persist { + match persist_root_string_key(app.config_path.as_deref(), "base_url", value) { + Ok(path) => { + return CommandResult::message(format!( + "base_url = {value} (saved to {})", + path.display() + )); + } + Err(err) => return CommandResult::error(format!("Failed to save: {err}")), + } + } + return CommandResult::error(format!( + "base_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving." + )); + } _ => {} } @@ -1750,6 +1790,134 @@ mod tests { assert!(saved.contains("cost_currency = \"cny\"")); } + #[test] + fn config_command_base_url_save_persists_value() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-base-url-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("base_url https://example.internal.local/v1 --save"), + ); + let msg = result.message.unwrap(); + let saved_path = config_toml_path(None).unwrap(); + let saved = fs::read_to_string(&saved_path).unwrap(); + + assert_eq!( + msg, + format!( + "base_url = https://example.internal.local/v1 (saved to {})", + saved_path.display() + ) + ); + assert!(saved.contains("base_url = \"https://example.internal.local/v1\"")); + } + + #[test] + fn config_command_base_url_without_save_requires_save() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + let result = config_command(&mut app, Some("base_url https://example.internal.local/v1")); + assert!(result.is_error); + let msg = result.message.unwrap(); + + assert!( + msg.contains("base_url must be saved with --save"), + "got {msg}" + ); + } + + #[test] + fn config_command_base_url_reads_current_value_from_config() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-base-url-show-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let config_path = temp_root.join(".deepseek").join("config.toml"); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write( + &config_path, + "base_url = \"https://api.from-config.local/v1\"\n", + ) + .unwrap(); + + let mut app = create_test_app(); + let result = config_command(&mut app, Some("base_url")); + let msg = result.message.unwrap(); + + assert_eq!(msg, "base_url = https://api.from-config.local/v1"); + } + + #[test] + fn config_command_base_url_reads_current_value_from_app_config_path() { + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-base-url-app-config-path-test-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + + let config_path = temp_root.join("custom-config.toml"); + fs::write( + &config_path, + "base_url = \"https://api.from-app-path.local/v1\"\n", + ) + .unwrap(); + + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + let result = config_command(&mut app, Some("base_url")); + let msg = result.message.unwrap(); + + assert_eq!(msg, "base_url = https://api.from-app-path.local/v1"); + } + + #[test] + fn config_command_base_url_save_persists_to_app_config_path() { + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-base-url-save-app-path-test-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + + let config_path = temp_root.join("custom-config.toml"); + + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + let result = config_command( + &mut app, + Some("base_url https://example.session.local/v1 --save"), + ); + let msg = result.message.unwrap(); + let saved = fs::read_to_string(&config_path).unwrap(); + + assert_eq!( + msg, + format!( + "base_url = https://example.session.local/v1 (saved to {})", + config_path.display() + ) + ); + assert!(saved.contains("base_url = \"https://example.session.local/v1\"")); + } + #[test] fn theme_command_accepts_grayscale_arg() { let nanos = SystemTime::now() diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 188b4d1f9..e9e53a747 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -693,8 +693,12 @@ pub fn persist_status_items( } /// Persist a root-level string key in `config.toml`. -pub fn persist_root_string_key(key: &str, value: &str) -> anyhow::Result { - config::persist_root_string_key(key, value) +pub fn persist_root_string_key( + config_path: Option<&std::path::Path>, + key: &str, + value: &str, +) -> anyhow::Result { + config::persist_root_string_key(config_path, key, value) } pub fn switch_mode(app: &mut App, mode: crate::tui::app::AppMode) -> String { diff --git a/crates/tui/src/commands/network.rs b/crates/tui/src/commands/network.rs index 563ded911..dbe0e7afe 100644 --- a/crates/tui/src/commands/network.rs +++ b/crates/tui/src/commands/network.rs @@ -70,7 +70,7 @@ enum NetworkEdit { } fn list_policy() -> anyhow::Result { - let path = super::config::config_toml_path()?; + let path = super::config::config_toml_path(None)?; let doc = load_config_doc(&path)?; let network = doc.get("network").and_then(Value::as_table); let default = network @@ -97,7 +97,7 @@ fn list_policy() -> anyhow::Result { } fn update_host(edit: NetworkEdit, host: &str) -> anyhow::Result { - let path = super::config::config_toml_path()?; + let path = super::config::config_toml_path(None)?; let mut doc = load_config_doc(&path)?; let network = network_table_mut(&mut doc)?; @@ -136,7 +136,7 @@ fn update_default(value: &str) -> anyhow::Result { _ => bail!("Usage: /network default "), }; - let path = super::config::config_toml_path()?; + let path = super::config::config_toml_path(None)?; let mut doc = load_config_doc(&path)?; let network = network_table_mut(&mut doc)?; network.insert("default".to_string(), Value::String(normalized.to_string())); diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index a0915dd82..e2dce67a8 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -686,7 +686,11 @@ fn apply_reasoning_effort( app.last_effective_reasoning_effort = None; app.update_model_compaction_budget(); if persist { - commands::persist_root_string_key("reasoning_effort", effort.as_setting())?; + commands::persist_root_string_key( + app.config_path.as_deref(), + "reasoning_effort", + effort.as_setting(), + )?; } config.reasoning_effort = Some(effort.as_setting().to_string()); Ok(()) diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index c0cfb8303..ef2fffd13 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -757,6 +757,10 @@ impl Settings { ), ("show_thinking", "Show model thinking: on/off"), ("show_tool_details", "Show detailed tool output: on/off"), + ( + "base_url", + "HTTP base URL for DeepSeek-compatible endpoints.", + ), ( "locale", "UI locale and default model language: auto, en, ja, zh-Hans, pt-BR, es-419", diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index fcf1eb40a..d419d15d7 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -3,6 +3,7 @@ use ratatui::{buffer::Buffer, layout::Rect}; use std::cell::{Cell, RefCell}; use std::fmt; +use crate::config::Config; use crate::localization::{Locale, MessageId, tr}; use crate::palette; use crate::settings::Settings; @@ -609,6 +610,15 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, + ConfigRow { + section: ConfigSection::Model, + key: "base_url".to_string(), + value: Config::load(app.config_path.clone(), app.config_profile.as_deref()) + .map(|config| config.deepseek_base_url()) + .unwrap_or_else(|_| "(unavailable)".to_string()), + editable: true, + scope: ConfigScope::Saved, + }, ConfigRow { section: ConfigSection::Permissions, key: "approval_mode".to_string(), @@ -1988,6 +1998,7 @@ mod tests { KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; use ratatui::{buffer::Buffer, layout::Rect}; + use std::fs; use std::path::PathBuf; fn create_test_app() -> App { @@ -2150,6 +2161,7 @@ mod tests { .collect::>(); assert!(keys.contains(&"model")); assert!(keys.contains(&"reasoning_effort")); + assert!(keys.contains(&"base_url")); assert!(keys.contains(&"approval_mode")); assert!(keys.contains(&"theme")); assert!(keys.contains(&"locale")); @@ -2168,6 +2180,32 @@ mod tests { assert!(view.rows.iter().all(|row| row.editable)); } + #[test] + fn config_view_base_url_reflects_app_config_path() { + let temp_root = std::env::temp_dir().join(format!( + "deepseek-tui-base-url-view-test-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + let config_path = temp_root.join("config.toml"); + fs::write( + &config_path, + "base_url = \"https://ui-config-view.local/v1\"\n", + ) + .unwrap(); + + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + let view = ConfigView::new_for_app(&app); + + let row = view + .rows + .iter() + .find(|row| row.key == "base_url") + .expect("base_url row missing"); + assert_eq!(row.value, "https://ui-config-view.local/v1"); + } + #[test] fn config_view_exposes_all_available_saved_settings() { let app = create_test_app();