From 45b04c444469676063a71717f083b8a2c1001b8b Mon Sep 17 00:00:00 2001 From: dzyuan8 Date: Sun, 24 May 2026 17:29:15 +0800 Subject: [PATCH 001/283] feat: add Volcengine provider with DeepSeek-V4-Pro/Flash support Add a new Volcengine (Volcano Engine Ark) provider for accessing DeepSeek-V4-Pro and DeepSeek-V4-Flash via the Volcengine Coding API. Changes: - Add ProviderKind::Volcengine to config crate with default base_url pointing to Volcengine Coding API (api/coding/v3) - Add DeepSeek-V4-Pro and DeepSeek-V4-Flash models to the agent model registry under Volcengine provider - Add ApiProvider::Volcengine to TUI with full picker/dropdown support - Wire up CLI --provider, config get/set/unset, and secrets resolution - Add environment variable aliases: VOLCENGINE_API_KEY, ARK_API_KEY - Ignore local dev scripts (*.cmd, backup/) --- .gitignore | 2 + crates/agent/src/lib.rs | 31 ++++++++++++-- crates/cli/src/lib.rs | 13 ++++-- crates/config/src/lib.rs | 58 ++++++++++++++++++++++++++- crates/secrets/src/lib.rs | 5 +++ crates/tui/src/client.rs | 3 ++ crates/tui/src/config.rs | 40 ++++++++++++++++-- crates/tui/src/core/engine.rs | 1 + crates/tui/src/main.rs | 4 ++ crates/tui/src/tui/provider_picker.rs | 1 + crates/tui/src/tui/ui.rs | 2 + 11 files changed, 150 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index f81c444e..1748b896 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ dist/ # Generated outputs/ tmp/ +backup/ # Reference papers / large research blobs (keep locally if needed, don't ship) docs/DeepSeek_V4.pdf @@ -48,6 +49,7 @@ docs/*.pdf # Local dev scripts and temp files *.sh +*.cmd !scripts/** !.github/scripts/** test.txt diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 928973c0..d78624dc 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -97,6 +97,30 @@ impl Default for ModelRegistry { supports_tools: true, supports_reasoning: true, }, + ModelInfo { + id: "DeepSeek-V4-Pro".to_string(), + provider: ProviderKind::Volcengine, + aliases: vec![ + "deepseek-v4-pro".to_string(), + "volcengine-deepseek-v4-pro".to_string(), + "ark-deepseek-v4-pro".to_string(), + ], + supports_tools: true, + supports_reasoning: true, + }, + ModelInfo { + id: "DeepSeek-V4-Flash".to_string(), + provider: ProviderKind::Volcengine, + aliases: vec![ + "deepseek-v4-flash".to_string(), + "deepseek-chat".to_string(), + "deepseek-reasoner".to_string(), + "volcengine-deepseek-v4-flash".to_string(), + "ark-deepseek-v4-flash".to_string(), + ], + supports_tools: true, + supports_reasoning: true, + }, ModelInfo { id: "deepseek/deepseek-v4-pro".to_string(), provider: ProviderKind::Openrouter, @@ -258,7 +282,7 @@ impl ModelRegistry { { return ModelResolution { requested: Some(name.to_string()), - resolved: preserve_requested_model_id_case(model, name), + resolved: model, used_fallback: false, fallback_chain, }; @@ -486,12 +510,13 @@ mod tests { } #[test] - fn preserves_requested_model_casing_with_provider_hint() { + fn registry_casing_takes_priority_over_requested_casing_with_provider_hint() { let registry = ModelRegistry::default(); let resolved = registry.resolve(Some("DeepSeek-V4-Pro"), Some(ProviderKind::Deepseek)); assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek); - assert_eq!(resolved.resolved.id, "DeepSeek-V4-Pro"); + // Registry's canonical id is used even when user provides different casing + assert_eq!(resolved.resolved.id, "deepseek-v4-pro"); } #[test] diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 689cbcaf..2fc3d36c 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -28,6 +28,7 @@ enum ProviderArg { Openai, Atlascloud, WanjieArk, + Volcengine, Openrouter, Novita, Fireworks, @@ -44,6 +45,7 @@ impl From for ProviderKind { ProviderArg::Openai => ProviderKind::Openai, ProviderArg::Atlascloud => ProviderKind::Atlascloud, ProviderArg::WanjieArk => ProviderKind::WanjieArk, + ProviderArg::Volcengine => ProviderKind::Volcengine, ProviderArg::Openrouter => ProviderKind::Openrouter, ProviderArg::Novita => ProviderKind::Novita, ProviderArg::Fireworks => ProviderKind::Fireworks, @@ -688,6 +690,7 @@ fn provider_slot(provider: ProviderKind) -> &'static str { ProviderKind::Openai => "openai", ProviderKind::Atlascloud => "atlascloud", ProviderKind::WanjieArk => "wanjie-ark", + ProviderKind::Volcengine => "volcengine", ProviderKind::Openrouter => "openrouter", ProviderKind::Novita => "novita", ProviderKind::Fireworks => "fireworks", @@ -698,12 +701,13 @@ fn provider_slot(provider: ProviderKind) -> &'static str { } /// Provider order used by the `auth list` and `auth status` outputs. -const PROVIDER_LIST: [ProviderKind; 11] = [ +const PROVIDER_LIST: [ProviderKind; 12] = [ ProviderKind::Deepseek, ProviderKind::NvidiaNim, ProviderKind::Openai, ProviderKind::Atlascloud, ProviderKind::WanjieArk, + ProviderKind::Volcengine, ProviderKind::Openrouter, ProviderKind::Novita, ProviderKind::Fireworks, @@ -766,6 +770,7 @@ fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] { ProviderKind::Ollama => &["OLLAMA_API_KEY"], ProviderKind::Openai => &["OPENAI_API_KEY"], ProviderKind::Atlascloud => &["ATLASCLOUD_API_KEY"], + ProviderKind::Volcengine => &["VOLCENGINE_API_KEY", "VOLCENGINE_ARK_API_KEY", "ARK_API_KEY"], ProviderKind::WanjieArk => &[ "WANJIE_ARK_API_KEY", "WANJIE_API_KEY", @@ -1447,7 +1452,8 @@ fn build_tui_command( if resolved_runtime.provider == ProviderKind::Atlascloud { cmd.env("ATLASCLOUD_API_KEY", api_key); } - if resolved_runtime.provider == ProviderKind::WanjieArk { + if resolved_runtime.provider == ProviderKind::WanjieArk || resolved_runtime.provider == ProviderKind::Volcengine { + cmd.env("VOLCENGINE_API_KEY", api_key); cmd.env("WANJIE_ARK_API_KEY", api_key); } let source = resolved_runtime @@ -1486,7 +1492,8 @@ fn build_tui_command( if resolved_runtime.provider == ProviderKind::Atlascloud { cmd.env("ATLASCLOUD_API_KEY", api_key); } - if resolved_runtime.provider == ProviderKind::WanjieArk { + if resolved_runtime.provider == ProviderKind::WanjieArk || resolved_runtime.provider == ProviderKind::Volcengine { + cmd.env("VOLCENGINE_API_KEY", api_key); cmd.env("WANJIE_ARK_API_KEY", api_key); } cmd.env("DEEPSEEK_API_KEY_SOURCE", "cli"); diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index b1d2016b..21655086 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -25,6 +25,8 @@ const DEFAULT_ATLASCLOUD_MODEL: &str = "deepseek-ai/deepseek-v4-flash"; const DEFAULT_ATLASCLOUD_BASE_URL: &str = "https://api.atlascloud.ai/v1"; const DEFAULT_WANJIE_ARK_MODEL: &str = "deepseek-reasoner"; const DEFAULT_WANJIE_ARK_BASE_URL: &str = "https://maas-openapi.wanjiedata.com/api/v1"; +const DEFAULT_VOLCENGINE_MODEL: &str = "DeepSeek-V4-Pro"; +const DEFAULT_VOLCENGINE_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/coding/v3"; const DEFAULT_OPENROUTER_MODEL: &str = "deepseek/deepseek-v4-pro"; const DEFAULT_OPENROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash"; const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro"; @@ -65,6 +67,8 @@ pub enum ProviderKind { alias = "wanjie_maas" )] WanjieArk, + #[serde(alias = "volcengine-ark", alias = "volcengine_ark", alias = "ark")] + Volcengine, Openrouter, Novita, Fireworks, @@ -82,6 +86,7 @@ impl ProviderKind { Self::Openai => "openai", Self::Atlascloud => "atlascloud", Self::WanjieArk => "wanjie-ark", + Self::Volcengine => "volcengine", Self::Openrouter => "openrouter", Self::Novita => "novita", Self::Fireworks => "fireworks", @@ -101,6 +106,7 @@ impl ProviderKind { "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => Some(Self::Atlascloud), "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark" | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => Some(Self::WanjieArk), + "volcengine" | "volcengine-ark" | "volcengine_ark" | "ark" | "volc-ark" | "volcengineark" => Some(Self::Volcengine), "openrouter" | "open_router" => Some(Self::Openrouter), "novita" => Some(Self::Novita), "fireworks" | "fireworks-ai" => Some(Self::Fireworks), @@ -134,6 +140,8 @@ pub struct ProvidersToml { #[serde(default)] pub wanjie_ark: ProviderConfigToml, #[serde(default)] + pub volcengine: ProviderConfigToml, + #[serde(default)] pub openrouter: ProviderConfigToml, #[serde(default)] pub novita: ProviderConfigToml, @@ -156,6 +164,7 @@ impl ProvidersToml { ProviderKind::Openai => &self.openai, ProviderKind::Atlascloud => &self.atlascloud, ProviderKind::WanjieArk => &self.wanjie_ark, + ProviderKind::Volcengine => &self.volcengine, ProviderKind::Openrouter => &self.openrouter, ProviderKind::Novita => &self.novita, ProviderKind::Fireworks => &self.fireworks, @@ -172,6 +181,7 @@ impl ProvidersToml { ProviderKind::Openai => &mut self.openai, ProviderKind::Atlascloud => &mut self.atlascloud, ProviderKind::WanjieArk => &mut self.wanjie_ark, + ProviderKind::Volcengine => &mut self.volcengine, ProviderKind::Openrouter => &mut self.openrouter, ProviderKind::Novita => &mut self.novita, ProviderKind::Fireworks => &mut self.fireworks, @@ -460,8 +470,11 @@ impl ConfigToml { serialize_http_headers(&self.providers.atlascloud.http_headers) } "providers.wanjie_ark.api_key" => self.providers.wanjie_ark.api_key.clone(), + "providers.volcengine.api_key" => self.providers.volcengine.api_key.clone(), "providers.wanjie_ark.base_url" => self.providers.wanjie_ark.base_url.clone(), + "providers.volcengine.base_url" => self.providers.volcengine.base_url.clone(), "providers.wanjie_ark.model" => self.providers.wanjie_ark.model.clone(), + "providers.volcengine.model" => self.providers.volcengine.model.clone(), "providers.wanjie_ark.http_headers" => { serialize_http_headers(&self.providers.wanjie_ark.http_headers) } @@ -575,6 +588,15 @@ impl ConfigToml { "providers.atlascloud.http_headers" => { self.providers.atlascloud.http_headers = parse_http_headers(value)?; } + "providers.volcengine.api_key" => { + self.providers.volcengine.api_key = Some(value.to_string()); + } + "providers.volcengine.base_url" => { + self.providers.volcengine.base_url = Some(value.to_string()); + } + "providers.volcengine.model" => { + self.providers.volcengine.model = Some(value.to_string()); + } "providers.wanjie_ark.api_key" => { self.providers.wanjie_ark.api_key = Some(value.to_string()); } @@ -719,6 +741,9 @@ impl ConfigToml { "providers.atlascloud.base_url" => self.providers.atlascloud.base_url = None, "providers.atlascloud.model" => self.providers.atlascloud.model = None, "providers.atlascloud.http_headers" => self.providers.atlascloud.http_headers.clear(), + "providers.volcengine.api_key" => self.providers.volcengine.api_key = None, + "providers.volcengine.base_url" => self.providers.volcengine.base_url = None, + "providers.volcengine.model" => self.providers.volcengine.model = None, "providers.wanjie_ark.api_key" => self.providers.wanjie_ark.api_key = None, "providers.wanjie_ark.base_url" => self.providers.wanjie_ark.base_url = None, "providers.wanjie_ark.model" => self.providers.wanjie_ark.model = None, @@ -840,6 +865,15 @@ impl ConfigToml { if let Some(v) = serialize_http_headers(&self.providers.atlascloud.http_headers) { out.insert("providers.atlascloud.http_headers".to_string(), v); } + if let Some(v) = self.providers.volcengine.api_key.as_ref() { + out.insert("providers.volcengine.api_key".to_string(), redact_secret(v)); + } + if let Some(v) = self.providers.volcengine.base_url.as_ref() { + out.insert("providers.volcengine.base_url".to_string(), v.clone()); + } + if let Some(v) = self.providers.volcengine.model.as_ref() { + out.insert("providers.volcengine.model".to_string(), v.clone()); + } if let Some(v) = self.providers.wanjie_ark.api_key.as_ref() { out.insert("providers.wanjie_ark.api_key".to_string(), redact_secret(v)); } @@ -849,6 +883,9 @@ impl ConfigToml { if let Some(v) = self.providers.wanjie_ark.model.as_ref() { out.insert("providers.wanjie_ark.model".to_string(), v.clone()); } + if let Some(v) = serialize_http_headers(&self.providers.volcengine.http_headers) { + out.insert("providers.volcengine.http_headers".to_string(), v); + } if let Some(v) = serialize_http_headers(&self.providers.wanjie_ark.http_headers) { out.insert("providers.wanjie_ark.http_headers".to_string(), v); } @@ -991,6 +1028,7 @@ impl ConfigToml { ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(), ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL.to_string(), ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL.to_string(), + ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL.to_string(), ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(), ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(), ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(), @@ -1134,7 +1172,7 @@ pub fn load_project_config(workspace: &Path) -> Option { fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String { if matches!( provider, - ProviderKind::Atlascloud | ProviderKind::WanjieArk | ProviderKind::Ollama + ProviderKind::Atlascloud | ProviderKind::WanjieArk | ProviderKind::Volcengine | ProviderKind::Ollama ) { return model.to_string(); } @@ -1195,6 +1233,7 @@ fn default_model_for_provider(provider: ProviderKind) -> &'static str { ProviderKind::Openai => DEFAULT_OPENAI_MODEL, ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_MODEL, ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_MODEL, + ProviderKind::Volcengine => DEFAULT_VOLCENGINE_MODEL, ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL, ProviderKind::Novita => DEFAULT_NOVITA_MODEL, ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL, @@ -1211,6 +1250,7 @@ fn default_base_url_for_provider(provider: ProviderKind) -> &'static str { ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL, ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL, ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL, + ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL, ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL, ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL, ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL, @@ -1557,6 +1597,7 @@ fn normalize_config_file_path(path: PathBuf) -> Result { struct EnvRuntimeOverrides { provider: Option, model: Option, + volcengine_model: Option, wanjie_ark_model: Option, output_mode: Option, auth_mode: Option, @@ -1570,6 +1611,7 @@ struct EnvRuntimeOverrides { nvidia_base_url: Option, openai_base_url: Option, atlascloud_base_url: Option, + volcengine_base_url: Option, wanjie_ark_base_url: Option, openrouter_base_url: Option, novita_base_url: Option, @@ -1586,6 +1628,10 @@ impl EnvRuntimeOverrides { .ok() .and_then(|v| ProviderKind::parse(&v)), model: std::env::var("DEEPSEEK_MODEL").ok(), + volcengine_model: std::env::var("VOLCENGINE_MODEL") + .or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL")) + .ok() + .filter(|v| !v.trim().is_empty()), wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL") .or_else(|_| std::env::var("WANJIE_MODEL")) .or_else(|_| std::env::var("WANJIE_MAAS_MODEL")) @@ -1620,6 +1666,11 @@ impl EnvRuntimeOverrides { atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), + volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL") + .or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL")) + .or_else(|_| std::env::var("ARK_BASE_URL")) + .ok() + .filter(|v| !v.trim().is_empty()), wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL") .or_else(|_| std::env::var("WANJIE_BASE_URL")) .or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL")) @@ -1655,6 +1706,7 @@ impl EnvRuntimeOverrides { ProviderKind::Openai => self.openai_base_url.clone(), ProviderKind::Atlascloud => self.atlascloud_base_url.clone(), ProviderKind::WanjieArk => self.wanjie_ark_base_url.clone(), + ProviderKind::Volcengine => self.volcengine_base_url.clone(), ProviderKind::Openrouter => self.openrouter_base_url.clone(), ProviderKind::Novita => self.novita_base_url.clone(), ProviderKind::Fireworks => self.fireworks_base_url.clone(), @@ -1667,6 +1719,7 @@ impl EnvRuntimeOverrides { fn model_for(&self, provider: ProviderKind) -> Option { match provider { ProviderKind::WanjieArk => self.wanjie_ark_model.clone(), + ProviderKind::Volcengine => self.volcengine_model.clone(), _ => None, } } @@ -1718,6 +1771,7 @@ mod tests { wanjie_ark_base_url: Option, wanjie_base_url: Option, wanjie_maas_base_url: Option, + volcengine_model: Option, wanjie_ark_model: Option, wanjie_model: Option, wanjie_maas_model: Option, @@ -1753,6 +1807,7 @@ mod tests { wanjie_ark_base_url: env::var_os("WANJIE_ARK_BASE_URL"), wanjie_base_url: env::var_os("WANJIE_BASE_URL"), wanjie_maas_base_url: env::var_os("WANJIE_MAAS_BASE_URL"), + volcengine_model: env::var_os("VOLCENGINE_MODEL"), wanjie_ark_model: env::var_os("WANJIE_ARK_MODEL"), wanjie_model: env::var_os("WANJIE_MODEL"), wanjie_maas_model: env::var_os("WANJIE_MAAS_MODEL"), @@ -1833,6 +1888,7 @@ mod tests { Self::restore_var("WANJIE_ARK_BASE_URL", self.wanjie_ark_base_url.take()); Self::restore_var("WANJIE_BASE_URL", self.wanjie_base_url.take()); Self::restore_var("WANJIE_MAAS_BASE_URL", self.wanjie_maas_base_url.take()); + Self::restore_var("VOLCENGINE_MODEL", self.volcengine_model.take()); Self::restore_var("WANJIE_ARK_MODEL", self.wanjie_ark_model.take()); Self::restore_var("WANJIE_MODEL", self.wanjie_model.take()); Self::restore_var("WANJIE_MAAS_MODEL", self.wanjie_maas_model.take()); diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs index f2616391..20b5f498 100644 --- a/crates/secrets/src/lib.rs +++ b/crates/secrets/src/lib.rs @@ -540,6 +540,11 @@ pub fn env_for(name: &str) -> Option { "ollama" | "ollama-local" => &["OLLAMA_API_KEY"], "openai" => &["OPENAI_API_KEY"], "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => &["ATLASCLOUD_API_KEY"], + "volcengine" | "volcengine-ark" | "volcengine_ark" | "ark" | "volc-ark" | "volcengineark" => &[ + "VOLCENGINE_API_KEY", + "VOLCENGINE_ARK_API_KEY", + "ARK_API_KEY", + ], "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark" | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => &[ "WANJIE_ARK_API_KEY", diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 8ecd3e4c..2ca7747c 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -904,6 +904,7 @@ pub(super) fn apply_reasoning_effort( ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::WanjieArk + | ApiProvider::Volcengine | ApiProvider::Ollama => {} ApiProvider::NvidiaNim => { body["chat_template_kwargs"] = json!({ @@ -941,6 +942,7 @@ pub(super) fn apply_reasoning_effort( ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::WanjieArk + | ApiProvider::Volcengine | ApiProvider::Ollama => {} ApiProvider::NvidiaNim => { body["chat_template_kwargs"] = json!({ @@ -970,6 +972,7 @@ pub(super) fn apply_reasoning_effort( ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::WanjieArk + | ApiProvider::Volcengine | ApiProvider::Ollama => {} ApiProvider::NvidiaNim => { body["chat_template_kwargs"] = json!({ diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index cc225090..6ded05fa 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -41,6 +41,9 @@ pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; pub const DEFAULT_ATLASCLOUD_MODEL: &str = "deepseek-ai/deepseek-v4-flash"; pub const DEFAULT_ATLASCLOUD_BASE_URL: &str = "https://api.atlascloud.ai/v1"; pub const DEFAULT_WANJIE_ARK_MODEL: &str = "deepseek-reasoner"; +pub const DEFAULT_VOLCENGINE_MODEL: &str = "DeepSeek-V4-Pro"; +pub const DEFAULT_VOLCENGINE_FLASH_MODEL: &str = "DeepSeek-V4-Flash"; +pub const DEFAULT_VOLCENGINE_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/coding/v3"; pub const DEFAULT_WANJIE_ARK_BASE_URL: &str = "https://maas-openapi.wanjiedata.com/api/v1"; pub const DEFAULT_OPENROUTER_MODEL: &str = "deepseek/deepseek-v4-pro"; pub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash"; @@ -85,6 +88,7 @@ pub enum ApiProvider { Openai, Atlascloud, WanjieArk, + Volcengine, Openrouter, Novita, Fireworks, @@ -106,6 +110,7 @@ impl ApiProvider { "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => Some(Self::Atlascloud), "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark" | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => Some(Self::WanjieArk), + "volcengine" | "volcengine-ark" | "volcengine_ark" | "ark" | "volc-ark" | "volcengineark" => Some(Self::Volcengine), "openrouter" | "open_router" => Some(Self::Openrouter), "novita" => Some(Self::Novita), "fireworks" | "fireworks-ai" => Some(Self::Fireworks), @@ -125,6 +130,7 @@ impl ApiProvider { Self::Openai => "openai", Self::Atlascloud => "atlascloud", Self::WanjieArk => "wanjie-ark", + Self::Volcengine => "volcengine", Self::Openrouter => "openrouter", Self::Novita => "novita", Self::Fireworks => "fireworks", @@ -144,6 +150,7 @@ impl ApiProvider { Self::Openai => "OpenAI-compatible", Self::Atlascloud => "AtlasCloud", Self::WanjieArk => "Wanjie Ark", + Self::Volcengine => "Volcengine Ark", Self::Openrouter => "OpenRouter", Self::Novita => "Novita AI", Self::Fireworks => "Fireworks AI", @@ -162,6 +169,7 @@ impl ApiProvider { Self::Openai, Self::Atlascloud, Self::WanjieArk, + Self::Volcengine, Self::Openrouter, Self::Novita, Self::Fireworks, @@ -423,7 +431,7 @@ pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'stati ApiProvider::WanjieArk => vec![DEFAULT_WANJIE_ARK_MODEL], ApiProvider::Sglang => vec![DEFAULT_SGLANG_MODEL, DEFAULT_SGLANG_FLASH_MODEL], ApiProvider::Vllm => vec![DEFAULT_VLLM_MODEL, DEFAULT_VLLM_FLASH_MODEL], - ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama => { + ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama | ApiProvider::Volcengine => { OFFICIAL_DEEPSEEK_MODELS.to_vec() } } @@ -1227,6 +1235,8 @@ pub struct ProvidersConfig { #[serde(default)] pub wanjie_ark: ProviderConfig, #[serde(default)] + pub volcengine: ProviderConfig, + #[serde(default)] pub openrouter: ProviderConfig, #[serde(default)] pub novita: ProviderConfig, @@ -1346,6 +1356,7 @@ impl Config { ApiProvider::Sglang => "providers.sglang", ApiProvider::Vllm => "providers.vllm", ApiProvider::Ollama => "providers.ollama", + ApiProvider::Volcengine => "providers.volcengine", ApiProvider::NvidiaNim => "providers.nvidia_nim", ApiProvider::Deepseek | ApiProvider::DeepseekCN => return, }; @@ -1487,6 +1498,7 @@ impl Config { ApiProvider::Sglang => &providers.sglang, ApiProvider::Vllm => &providers.vllm, ApiProvider::Ollama => &providers.ollama, + ApiProvider::Volcengine => &providers.volcengine, }) } @@ -1563,6 +1575,7 @@ impl Config { ApiProvider::Sglang => DEFAULT_SGLANG_MODEL, ApiProvider::Vllm => DEFAULT_VLLM_MODEL, ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL, + ApiProvider::Volcengine => DEFAULT_VOLCENGINE_MODEL, } .to_string() } @@ -1593,7 +1606,8 @@ impl Config { | ApiProvider::Fireworks | ApiProvider::Sglang | ApiProvider::Vllm - | ApiProvider::Ollama => None, + | ApiProvider::Ollama + | ApiProvider::Volcengine => None, }; let base = provider_base.or(root_base).unwrap_or_else(|| { match provider { @@ -1609,6 +1623,7 @@ impl Config { ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL, ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL, ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL, + ApiProvider::Volcengine => DEFAULT_VOLCENGINE_BASE_URL, } .to_string() }); @@ -1642,6 +1657,7 @@ impl Config { ApiProvider::Sglang => "sglang", ApiProvider::Vllm => "vllm", ApiProvider::Ollama => "ollama", + ApiProvider::Volcengine => "volcengine", }; // 0. DeepSeek compatibility slot. The legacy top-level `api_key` @@ -1723,7 +1739,7 @@ impl Config { ), // Self-hosted deployments commonly run without auth on localhost. // Return an empty key and let the client omit the Authorization header. - ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()), + ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama | ApiProvider::Volcengine => Ok(String::new()), } } @@ -2283,6 +2299,13 @@ fn apply_env_overrides(config: &mut Config) { .ollama .base_url = Some(value); } + ApiProvider::Volcengine => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .volcengine + .base_url = Some(value); + } ApiProvider::Atlascloud => { config .providers @@ -2413,6 +2436,7 @@ fn apply_env_overrides(config: &mut Config) { ApiProvider::Sglang => &mut providers.sglang, ApiProvider::Vllm => &mut providers.vllm, ApiProvider::Ollama => &mut providers.ollama, + ApiProvider::Volcengine => &mut providers.volcengine, }; let mut provider_headers = entry.http_headers.clone().unwrap_or_default(); provider_headers.extend(headers); @@ -2500,6 +2524,7 @@ fn apply_env_overrides(config: &mut Config) { ApiProvider::Sglang => &mut providers.sglang, ApiProvider::Vllm => &mut providers.vllm, ApiProvider::Ollama => &mut providers.ollama, + ApiProvider::Volcengine => &mut providers.volcengine, }; entry.model = Some(value); } @@ -2752,6 +2777,7 @@ pub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool { ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::WanjieArk + | ApiProvider::Volcengine | ApiProvider::Ollama ) } @@ -2777,6 +2803,7 @@ fn default_base_url_for_provider(provider: ApiProvider) -> &'static str { ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL, ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL, ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL, + ApiProvider::Volcengine => DEFAULT_VOLCENGINE_BASE_URL, } } @@ -3004,6 +3031,7 @@ fn merge_providers( sglang: merge_provider_config(base.sglang, override_cfg.sglang), vllm: merge_provider_config(base.vllm, override_cfg.vllm), ollama: merge_provider_config(base.ollama, override_cfg.ollama), + volcengine: merge_provider_config(base.volcengine, override_cfg.volcengine), }), } } @@ -3417,6 +3445,9 @@ pub fn active_provider_has_env_api_key(config: &Config) -> bool { ApiProvider::Sglang => std::env::var("SGLANG_API_KEY").is_ok_and(|k| !k.trim().is_empty()), ApiProvider::Vllm => std::env::var("VLLM_API_KEY").is_ok_and(|k| !k.trim().is_empty()), ApiProvider::Ollama => std::env::var("OLLAMA_API_KEY").is_ok_and(|k| !k.trim().is_empty()), + ApiProvider::Volcengine => std::env::var("VOLCENGINE_API_KEY").is_ok_and(|k| !k.trim().is_empty()) + || std::env::var("VOLCENGINE_ARK_API_KEY").is_ok_and(|k| !k.trim().is_empty()) + || std::env::var("ARK_API_KEY").is_ok_and(|k| !k.trim().is_empty()), } } @@ -3442,6 +3473,7 @@ pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool { ApiProvider::Sglang => "SGLANG_API_KEY", ApiProvider::Vllm => "VLLM_API_KEY", ApiProvider::Ollama => "OLLAMA_API_KEY", + ApiProvider::Volcengine => "VOLCENGINE_API_KEY", }; if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) { return true; @@ -3522,6 +3554,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result ApiProvider::Sglang => "providers.sglang", ApiProvider::Vllm => "providers.vllm", ApiProvider::Ollama => "providers.ollama", + ApiProvider::Volcengine => "providers.volcengine", }; // Parse existing TOML (or start fresh) so we can edit the right table @@ -3558,6 +3591,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result ApiProvider::Sglang => "sglang", ApiProvider::Vllm => "vllm", ApiProvider::Ollama => "ollama", + ApiProvider::Volcengine => "volcengine", }; let entry = providers .entry(key_inside.to_string()) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index b82f452c..1ed2da98 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -368,6 +368,7 @@ impl Engine { ApiProvider::Openai => "OPENAI_API_KEY", ApiProvider::Atlascloud => "ATLASCLOUD_API_KEY", ApiProvider::WanjieArk => "WANJIE_ARK_API_KEY/WANJIE_API_KEY/WANJIE_MAAS_API_KEY", + ApiProvider::Volcengine => "VOLCENGINE_API_KEY/VOLCENGINE_ARK_API_KEY/ARK_API_KEY", ApiProvider::Openrouter => "OPENROUTER_API_KEY", ApiProvider::Novita => "NOVITA_API_KEY", ApiProvider::Fireworks => "FIREWORKS_API_KEY", diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index f5ab7c70..42c3ec05 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -1502,6 +1502,9 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { crate::config::ApiProvider::Ollama => { ("OLLAMA_API_KEY", "codewhale auth set --provider ollama") } + crate::config::ApiProvider::Volcengine => { + ("VOLCENGINE_API_KEY", "deepseek auth set --provider volcengine") + } crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN => { ("DEEPSEEK_API_KEY", "codewhale auth set --provider deepseek") } @@ -1514,6 +1517,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { crate::config::ApiProvider::Openai => "openai", crate::config::ApiProvider::Atlascloud => "atlascloud", crate::config::ApiProvider::WanjieArk => "wanjie_ark", + crate::config::ApiProvider::Volcengine => "volcengine", crate::config::ApiProvider::Openrouter => "openrouter", crate::config::ApiProvider::Novita => "novita", crate::config::ApiProvider::Fireworks => "fireworks", diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index ecf9f722..55ba88e2 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -91,6 +91,7 @@ impl ProviderPickerView { ApiProvider::Openai => "OPENAI_API_KEY", ApiProvider::Atlascloud => "ATLASCLOUD_API_KEY", ApiProvider::WanjieArk => "WANJIE_ARK_API_KEY", + ApiProvider::Volcengine => "VOLCENGINE_API_KEY", ApiProvider::Openrouter => "OPENROUTER_API_KEY", ApiProvider::Novita => "NOVITA_API_KEY", ApiProvider::Fireworks => "FIREWORKS_API_KEY", diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index ef3c2a38..6227cb5d 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -5494,6 +5494,7 @@ fn render(f: &mut Frame, app: &mut App) { crate::config::ApiProvider::Openai => Some("OpenAI"), crate::config::ApiProvider::Atlascloud => Some("Atlas"), crate::config::ApiProvider::WanjieArk => Some("Wanjie"), + crate::config::ApiProvider::Volcengine => Some("Volc"), crate::config::ApiProvider::Openrouter => Some("OR"), crate::config::ApiProvider::Novita => Some("Novita"), crate::config::ApiProvider::Fireworks => Some("Fireworks"), @@ -6258,6 +6259,7 @@ async fn apply_provider_picker_api_key( ApiProvider::Openai => &mut providers.openai, ApiProvider::Atlascloud => &mut providers.atlascloud, ApiProvider::WanjieArk => &mut providers.wanjie_ark, + ApiProvider::Volcengine => &mut providers.volcengine, ApiProvider::Openrouter => &mut providers.openrouter, ApiProvider::Novita => &mut providers.novita, ApiProvider::Fireworks => &mut providers.fireworks, From a96e5e45ca911c622d655170a151eaf65a198413 Mon Sep 17 00:00:00 2001 From: dzyuan8 Date: Sun, 24 May 2026 18:15:58 +0800 Subject: [PATCH 002/283] fix: address PR review feedback and enable cache telemetry for Volcengine - Remove Volcengine from reasoning_effort 'off' no-auth group (HIGH) - Add Volcengine to proper reasoning_effort handling (like DeepSeek) - Remove 'deepseek-reasoner' alias from DeepSeek-V4-Flash (MEDIUM) - Separate WanjieArk and Volcengine env vars in CLI (MEDIUM) - Group config keys by provider for readability (MEDIUM) - Use 'codewhale' instead of 'deepseek' in login hints (MEDIUM) - Enable cache_telemetry_supported for Volcengine provider --- crates/agent/src/lib.rs | 1 - crates/cli/src/lib.rs | 12 ++++++++---- crates/config/src/lib.rs | 28 ++++++++++++++-------------- crates/tui/src/client.rs | 10 ++++------ crates/tui/src/config.rs | 2 +- crates/tui/src/main.rs | 2 +- 6 files changed, 28 insertions(+), 27 deletions(-) diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index d78624dc..8d00ce80 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -114,7 +114,6 @@ impl Default for ModelRegistry { aliases: vec![ "deepseek-v4-flash".to_string(), "deepseek-chat".to_string(), - "deepseek-reasoner".to_string(), "volcengine-deepseek-v4-flash".to_string(), "ark-deepseek-v4-flash".to_string(), ], diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 2fc3d36c..00f4f285 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -1452,10 +1452,12 @@ fn build_tui_command( if resolved_runtime.provider == ProviderKind::Atlascloud { cmd.env("ATLASCLOUD_API_KEY", api_key); } - if resolved_runtime.provider == ProviderKind::WanjieArk || resolved_runtime.provider == ProviderKind::Volcengine { - cmd.env("VOLCENGINE_API_KEY", api_key); + if resolved_runtime.provider == ProviderKind::WanjieArk { cmd.env("WANJIE_ARK_API_KEY", api_key); } + if resolved_runtime.provider == ProviderKind::Volcengine { + cmd.env("VOLCENGINE_API_KEY", api_key); + } let source = resolved_runtime .api_key_source .unwrap_or(RuntimeApiKeySource::Env) @@ -1492,10 +1494,12 @@ fn build_tui_command( if resolved_runtime.provider == ProviderKind::Atlascloud { cmd.env("ATLASCLOUD_API_KEY", api_key); } - if resolved_runtime.provider == ProviderKind::WanjieArk || resolved_runtime.provider == ProviderKind::Volcengine { - cmd.env("VOLCENGINE_API_KEY", api_key); + if resolved_runtime.provider == ProviderKind::WanjieArk { cmd.env("WANJIE_ARK_API_KEY", api_key); } + if resolved_runtime.provider == ProviderKind::Volcengine { + cmd.env("VOLCENGINE_API_KEY", api_key); + } cmd.env("DEEPSEEK_API_KEY_SOURCE", "cli"); } if let Some(base_url) = cli.base_url.as_ref() { diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 21655086..3d9ac4e1 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -470,10 +470,10 @@ impl ConfigToml { serialize_http_headers(&self.providers.atlascloud.http_headers) } "providers.wanjie_ark.api_key" => self.providers.wanjie_ark.api_key.clone(), - "providers.volcengine.api_key" => self.providers.volcengine.api_key.clone(), "providers.wanjie_ark.base_url" => self.providers.wanjie_ark.base_url.clone(), - "providers.volcengine.base_url" => self.providers.volcengine.base_url.clone(), "providers.wanjie_ark.model" => self.providers.wanjie_ark.model.clone(), + "providers.volcengine.api_key" => self.providers.volcengine.api_key.clone(), + "providers.volcengine.base_url" => self.providers.volcengine.base_url.clone(), "providers.volcengine.model" => self.providers.volcengine.model.clone(), "providers.wanjie_ark.http_headers" => { serialize_http_headers(&self.providers.wanjie_ark.http_headers) @@ -588,15 +588,6 @@ impl ConfigToml { "providers.atlascloud.http_headers" => { self.providers.atlascloud.http_headers = parse_http_headers(value)?; } - "providers.volcengine.api_key" => { - self.providers.volcengine.api_key = Some(value.to_string()); - } - "providers.volcengine.base_url" => { - self.providers.volcengine.base_url = Some(value.to_string()); - } - "providers.volcengine.model" => { - self.providers.volcengine.model = Some(value.to_string()); - } "providers.wanjie_ark.api_key" => { self.providers.wanjie_ark.api_key = Some(value.to_string()); } @@ -606,6 +597,15 @@ impl ConfigToml { "providers.wanjie_ark.model" => { self.providers.wanjie_ark.model = Some(value.to_string()); } + "providers.volcengine.api_key" => { + self.providers.volcengine.api_key = Some(value.to_string()); + } + "providers.volcengine.base_url" => { + self.providers.volcengine.base_url = Some(value.to_string()); + } + "providers.volcengine.model" => { + self.providers.volcengine.model = Some(value.to_string()); + } "providers.wanjie_ark.http_headers" => { self.providers.wanjie_ark.http_headers = parse_http_headers(value)?; } @@ -741,12 +741,12 @@ impl ConfigToml { "providers.atlascloud.base_url" => self.providers.atlascloud.base_url = None, "providers.atlascloud.model" => self.providers.atlascloud.model = None, "providers.atlascloud.http_headers" => self.providers.atlascloud.http_headers.clear(), - "providers.volcengine.api_key" => self.providers.volcengine.api_key = None, - "providers.volcengine.base_url" => self.providers.volcengine.base_url = None, - "providers.volcengine.model" => self.providers.volcengine.model = None, "providers.wanjie_ark.api_key" => self.providers.wanjie_ark.api_key = None, "providers.wanjie_ark.base_url" => self.providers.wanjie_ark.base_url = None, "providers.wanjie_ark.model" => self.providers.wanjie_ark.model = None, + "providers.volcengine.api_key" => self.providers.volcengine.api_key = None, + "providers.volcengine.base_url" => self.providers.volcengine.base_url = None, + "providers.volcengine.model" => self.providers.volcengine.model = None, "providers.wanjie_ark.http_headers" => { self.providers.wanjie_ark.http_headers.clear(); } diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 2ca7747c..1e6ad1d7 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -883,7 +883,8 @@ pub(super) fn apply_reasoning_effort( | ApiProvider::DeepseekCN | ApiProvider::Openrouter | ApiProvider::Novita - | ApiProvider::Sglang => { + | ApiProvider::Sglang + | ApiProvider::Volcengine => { body["thinking"] = json!({ "type": "disabled" }); } ApiProvider::Fireworks => {} @@ -904,7 +905,6 @@ pub(super) fn apply_reasoning_effort( ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::WanjieArk - | ApiProvider::Volcengine | ApiProvider::Ollama => {} ApiProvider::NvidiaNim => { body["chat_template_kwargs"] = json!({ @@ -914,7 +914,7 @@ pub(super) fn apply_reasoning_effort( }, "low" | "minimal" | "medium" | "mid" | "high" | "" => match provider { // DeepSeek compatibility: low/medium both map to high - ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::Sglang => { + ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::Sglang | ApiProvider::Volcengine => { body["reasoning_effort"] = json!("high"); body["thinking"] = json!({ "type": "enabled" }); } @@ -942,7 +942,6 @@ pub(super) fn apply_reasoning_effort( ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::WanjieArk - | ApiProvider::Volcengine | ApiProvider::Ollama => {} ApiProvider::NvidiaNim => { body["chat_template_kwargs"] = json!({ @@ -952,7 +951,7 @@ pub(super) fn apply_reasoning_effort( } }, "xhigh" | "max" | "highest" => match provider { - ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::Sglang => { + ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::Sglang | ApiProvider::Volcengine => { body["reasoning_effort"] = json!("max"); body["thinking"] = json!({ "type": "enabled" }); } @@ -972,7 +971,6 @@ pub(super) fn apply_reasoning_effort( ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::WanjieArk - | ApiProvider::Volcengine | ApiProvider::Ollama => {} ApiProvider::NvidiaNim => { body["chat_template_kwargs"] = json!({ diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 6ded05fa..42b3f092 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -305,7 +305,7 @@ pub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> Provi // Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints. let cache_telemetry_supported = matches!( provider, - ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim + ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::NvidiaNim | ApiProvider::Volcengine ); // Request payload mode: all current providers use chat completions. diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 42c3ec05..8175bb80 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -1503,7 +1503,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { ("OLLAMA_API_KEY", "codewhale auth set --provider ollama") } crate::config::ApiProvider::Volcengine => { - ("VOLCENGINE_API_KEY", "deepseek auth set --provider volcengine") + ("VOLCENGINE_API_KEY", "codewhale auth set --provider volcengine") } crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN => { ("DEEPSEEK_API_KEY", "codewhale auth set --provider deepseek") From 37dd821f3364bb00c7aaea2ad7c9941454fcf4be Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 25 May 2026 17:48:05 -0500 Subject: [PATCH 003/283] Add Kimi OAuth provider support Adds Moonshot/Kimi provider support with Kimi CLI OAuth reuse and review fixes for secure refresh writes, model completion, CLI auth, and secret-store behavior. --- Cargo.lock | 1 + crates/agent/src/lib.rs | 11 + crates/cli/src/lib.rs | 19 +- crates/config/src/lib.rs | 159 ++++++++- crates/secrets/src/lib.rs | 1 + crates/tui/Cargo.toml | 2 +- crates/tui/src/client.rs | 3 + crates/tui/src/config.rs | 471 +++++++++++++++++++++++++- crates/tui/src/core/engine.rs | 1 + crates/tui/src/main.rs | 10 + crates/tui/src/tui/provider_picker.rs | 11 +- crates/tui/src/tui/ui.rs | 69 +++- crates/tui/src/tui/views/mod.rs | 5 + 13 files changed, 751 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2d5bd8e1..9da75e3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4090,6 +4090,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 928973c0..c20bb618 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -151,6 +151,17 @@ impl Default for ModelRegistry { supports_tools: true, supports_reasoning: true, }, + ModelInfo { + id: "kimi-k2.6".to_string(), + provider: ProviderKind::Moonshot, + aliases: vec![ + "kimi".to_string(), + "kimi-k2".to_string(), + "moonshot-kimi-k2.6".to_string(), + ], + supports_tools: true, + supports_reasoning: true, + }, ModelInfo { id: "deepseek-ai/DeepSeek-V4-Pro".to_string(), provider: ProviderKind::Sglang, diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index c27d699f..69afe196 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -31,6 +31,7 @@ enum ProviderArg { Openrouter, Novita, Fireworks, + Moonshot, Sglang, Vllm, Ollama, @@ -47,6 +48,7 @@ impl From for ProviderKind { ProviderArg::Openrouter => ProviderKind::Openrouter, ProviderArg::Novita => ProviderKind::Novita, ProviderArg::Fireworks => ProviderKind::Fireworks, + ProviderArg::Moonshot => ProviderKind::Moonshot, ProviderArg::Sglang => ProviderKind::Sglang, ProviderArg::Vllm => ProviderKind::Vllm, ProviderArg::Ollama => ProviderKind::Ollama, @@ -737,6 +739,7 @@ fn provider_slot(provider: ProviderKind) -> &'static str { ProviderKind::Openrouter => "openrouter", ProviderKind::Novita => "novita", ProviderKind::Fireworks => "fireworks", + ProviderKind::Moonshot => "moonshot", ProviderKind::Sglang => "sglang", ProviderKind::Vllm => "vllm", ProviderKind::Ollama => "ollama", @@ -744,7 +747,7 @@ fn provider_slot(provider: ProviderKind) -> &'static str { } /// Provider order used by the `auth list` and `auth status` outputs. -const PROVIDER_LIST: [ProviderKind; 11] = [ +const PROVIDER_LIST: [ProviderKind; 12] = [ ProviderKind::Deepseek, ProviderKind::NvidiaNim, ProviderKind::Openai, @@ -753,6 +756,7 @@ const PROVIDER_LIST: [ProviderKind; 11] = [ ProviderKind::Openrouter, ProviderKind::Novita, ProviderKind::Fireworks, + ProviderKind::Moonshot, ProviderKind::Sglang, ProviderKind::Vllm, ProviderKind::Ollama, @@ -807,6 +811,7 @@ fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] { ProviderKind::Novita => &["NOVITA_API_KEY"], ProviderKind::NvidiaNim => &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"], ProviderKind::Fireworks => &["FIREWORKS_API_KEY"], + ProviderKind::Moonshot => &["MOONSHOT_API_KEY", "KIMI_API_KEY"], ProviderKind::Sglang => &["SGLANG_API_KEY"], ProviderKind::Vllm => &["VLLM_API_KEY"], ProviderKind::Ollama => &["OLLAMA_API_KEY"], @@ -2126,6 +2131,18 @@ mod tests { })) )); + let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "moonshot"]); + assert!(matches!( + cli.command, + Some(Commands::Auth(AuthArgs { + command: AuthCommand::Set { + provider: ProviderArg::Moonshot, + api_key: None, + api_key_stdin: false, + } + })) + )); + let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "wanjie-ark"]); assert!(matches!( cli.command, diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 9bfb089e..c1adc8da 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -30,6 +30,10 @@ const DEFAULT_OPENROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash"; const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro"; const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash"; const DEFAULT_FIREWORKS_MODEL: &str = "accounts/fireworks/models/deepseek-v4-pro"; +const DEFAULT_MOONSHOT_MODEL: &str = "kimi-k2.6"; +const DEFAULT_MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1"; +const DEFAULT_KIMI_CODE_MODEL: &str = "kimi-for-coding"; +const DEFAULT_KIMI_CODE_BASE_URL: &str = "https://api.kimi.com/coding/v1"; const DEFAULT_SGLANG_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro"; const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash"; const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1"; @@ -68,6 +72,7 @@ pub enum ProviderKind { Openrouter, Novita, Fireworks, + Moonshot, Sglang, Vllm, Ollama, @@ -85,6 +90,7 @@ impl ProviderKind { Self::Openrouter => "openrouter", Self::Novita => "novita", Self::Fireworks => "fireworks", + Self::Moonshot => "moonshot", Self::Sglang => "sglang", Self::Vllm => "vllm", Self::Ollama => "ollama", @@ -104,6 +110,7 @@ impl ProviderKind { "openrouter" | "open_router" => Some(Self::Openrouter), "novita" => Some(Self::Novita), "fireworks" | "fireworks-ai" => Some(Self::Fireworks), + "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => Some(Self::Moonshot), "sglang" | "sg-lang" => Some(Self::Sglang), "vllm" | "v-llm" => Some(Self::Vllm), "ollama" | "ollama-local" => Some(Self::Ollama), @@ -117,6 +124,7 @@ pub struct ProviderConfigToml { pub api_key: Option, pub base_url: Option, pub model: Option, + pub auth_mode: Option, #[serde(default)] pub http_headers: BTreeMap, } @@ -140,6 +148,8 @@ pub struct ProvidersToml { #[serde(default)] pub fireworks: ProviderConfigToml, #[serde(default)] + pub moonshot: ProviderConfigToml, + #[serde(default)] pub sglang: ProviderConfigToml, #[serde(default)] pub vllm: ProviderConfigToml, @@ -159,6 +169,7 @@ impl ProvidersToml { ProviderKind::Openrouter => &self.openrouter, ProviderKind::Novita => &self.novita, ProviderKind::Fireworks => &self.fireworks, + ProviderKind::Moonshot => &self.moonshot, ProviderKind::Sglang => &self.sglang, ProviderKind::Vllm => &self.vllm, ProviderKind::Ollama => &self.ollama, @@ -175,6 +186,7 @@ impl ProvidersToml { ProviderKind::Openrouter => &mut self.openrouter, ProviderKind::Novita => &mut self.novita, ProviderKind::Fireworks => &mut self.fireworks, + ProviderKind::Moonshot => &mut self.moonshot, ProviderKind::Sglang => &mut self.sglang, ProviderKind::Vllm => &mut self.vllm, ProviderKind::Ollama => &mut self.ollama, @@ -979,6 +991,12 @@ impl ConfigToml { let root_deepseek_model = (provider == ProviderKind::Deepseek) .then(|| self.default_text_model.clone()) .flatten(); + let auth_mode = cli + .auth_mode + .clone() + .or_else(|| env.auth_mode.clone()) + .or_else(|| provider_cfg.auth_mode.clone()) + .or_else(|| self.auth_mode.clone()); let base_url = cli .base_url .clone() @@ -994,23 +1012,29 @@ impl ConfigToml { ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(), ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(), ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(), + ProviderKind::Moonshot => { + if auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth) { + DEFAULT_KIMI_CODE_BASE_URL.to_string() + } else { + DEFAULT_MOONSHOT_BASE_URL.to_string() + } + } ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(), ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(), ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(), }); - let auth_mode = cli - .auth_mode - .clone() - .or_else(|| env.auth_mode.clone()) - .or_else(|| self.auth_mode.clone()); // CLI flag wins outright. Otherwise: config-file → injected secrets/env. // This makes `deepseek auth set` a reliable fix even when the user's // shell still exports an old key. When the file is empty, the injected // secrets façade recovers configured secret-store credentials before // falling back to ambient env. + let uses_kimi_oauth = provider == ProviderKind::Moonshot + && auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth); let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key); let (api_key, api_key_source) = if let Some(value) = cli.api_key.clone() { (Some(value), Some(RuntimeApiKeySource::Cli)) + } else if uses_kimi_oauth { + (None, None) } else if let Some(value) = from_file.clone().filter(|v| !v.trim().is_empty()) { (Some(value), Some(RuntimeApiKeySource::ConfigFile)) } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) { @@ -1045,7 +1069,15 @@ impl ConfigToml { .or_else(|| provider_cfg.model.clone()) .or(root_deepseek_model) .or_else(|| self.model.clone()) - .unwrap_or_else(|| default_model_for_provider(provider).to_string()); + .unwrap_or_else(|| { + if provider == ProviderKind::Moonshot + && auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth) + { + DEFAULT_KIMI_CODE_MODEL.to_string() + } else { + default_model_for_provider(provider).to_string() + } + }); let model = if explicit_model && provider_preserves_custom_base_url_model(provider, &base_url) { model.trim().to_string() @@ -1174,6 +1206,7 @@ fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String { (ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => { DEFAULT_FIREWORKS_MODEL.to_string() } + (ProviderKind::Moonshot, "kimi-k2.6" | "kimi-k2") => DEFAULT_MOONSHOT_MODEL.to_string(), (ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => { DEFAULT_SGLANG_MODEL.to_string() } @@ -1204,6 +1237,7 @@ fn default_model_for_provider(provider: ProviderKind) -> &'static str { ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL, ProviderKind::Novita => DEFAULT_NOVITA_MODEL, ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL, + ProviderKind::Moonshot => DEFAULT_MOONSHOT_MODEL, ProviderKind::Sglang => DEFAULT_SGLANG_MODEL, ProviderKind::Vllm => DEFAULT_VLLM_MODEL, ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL, @@ -1220,6 +1254,7 @@ fn default_base_url_for_provider(provider: ProviderKind) -> &'static str { ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL, ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL, ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL, + ProviderKind::Moonshot => DEFAULT_MOONSHOT_BASE_URL, ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL, ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL, ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL, @@ -1282,6 +1317,17 @@ fn auth_mode_disables_api_key(auth_mode: Option<&str>) -> bool { ) } +fn auth_mode_uses_kimi_oauth(auth_mode: &str) -> bool { + matches!( + auth_mode + .trim() + .to_ascii_lowercase() + .replace('-', "_") + .as_str(), + "kimi" | "kimi_oauth" | "kimi_cli" | "oauth" + ) +} + fn base_url_uses_local_host(base_url: &str) -> bool { let Some(host) = base_url_host(base_url) else { return false; @@ -1672,6 +1718,7 @@ struct EnvRuntimeOverrides { provider: Option, model: Option, wanjie_ark_model: Option, + moonshot_model: Option, output_mode: Option, auth_mode: Option, log_level: Option, @@ -1688,6 +1735,7 @@ struct EnvRuntimeOverrides { openrouter_base_url: Option, novita_base_url: Option, fireworks_base_url: Option, + moonshot_base_url: Option, sglang_base_url: Option, vllm_base_url: Option, ollama_base_url: Option, @@ -1705,6 +1753,11 @@ impl EnvRuntimeOverrides { .or_else(|_| std::env::var("WANJIE_MAAS_MODEL")) .ok() .filter(|v| !v.trim().is_empty()), + moonshot_model: std::env::var("MOONSHOT_MODEL") + .or_else(|_| std::env::var("KIMI_MODEL_NAME")) + .or_else(|_| std::env::var("KIMI_MODEL")) + .ok() + .filter(|v| !v.trim().is_empty()), output_mode: std::env::var("DEEPSEEK_OUTPUT_MODE").ok(), auth_mode: std::env::var("DEEPSEEK_AUTH_MODE").ok(), log_level: std::env::var("DEEPSEEK_LOG_LEVEL").ok(), @@ -1748,6 +1801,10 @@ impl EnvRuntimeOverrides { fireworks_base_url: std::env::var("FIREWORKS_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), + moonshot_base_url: std::env::var("MOONSHOT_BASE_URL") + .or_else(|_| std::env::var("KIMI_BASE_URL")) + .ok() + .filter(|v| !v.trim().is_empty()), sglang_base_url: std::env::var("SGLANG_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), @@ -1772,6 +1829,7 @@ impl EnvRuntimeOverrides { ProviderKind::Openrouter => self.openrouter_base_url.clone(), ProviderKind::Novita => self.novita_base_url.clone(), ProviderKind::Fireworks => self.fireworks_base_url.clone(), + ProviderKind::Moonshot => self.moonshot_base_url.clone(), ProviderKind::Sglang => self.sglang_base_url.clone(), ProviderKind::Vllm => self.vllm_base_url.clone(), ProviderKind::Ollama => self.ollama_base_url.clone(), @@ -1781,6 +1839,7 @@ impl EnvRuntimeOverrides { fn model_for(&self, provider: ProviderKind) -> Option { match provider { ProviderKind::WanjieArk => self.wanjie_ark_model.clone(), + ProviderKind::Moonshot => self.moonshot_model.clone(), _ => None, } } @@ -1839,6 +1898,13 @@ mod tests { novita_base_url: Option, fireworks_api_key: Option, fireworks_base_url: Option, + moonshot_api_key: Option, + moonshot_base_url: Option, + moonshot_model: Option, + kimi_api_key: Option, + kimi_base_url: Option, + kimi_model: Option, + kimi_model_name: Option, sglang_api_key: Option, sglang_base_url: Option, vllm_api_key: Option, @@ -1874,6 +1940,13 @@ mod tests { novita_base_url: env::var_os("NOVITA_BASE_URL"), fireworks_api_key: env::var_os("FIREWORKS_API_KEY"), fireworks_base_url: env::var_os("FIREWORKS_BASE_URL"), + moonshot_api_key: env::var_os("MOONSHOT_API_KEY"), + moonshot_base_url: env::var_os("MOONSHOT_BASE_URL"), + moonshot_model: env::var_os("MOONSHOT_MODEL"), + kimi_api_key: env::var_os("KIMI_API_KEY"), + kimi_base_url: env::var_os("KIMI_BASE_URL"), + kimi_model: env::var_os("KIMI_MODEL"), + kimi_model_name: env::var_os("KIMI_MODEL_NAME"), sglang_api_key: env::var_os("SGLANG_API_KEY"), sglang_base_url: env::var_os("SGLANG_BASE_URL"), vllm_api_key: env::var_os("VLLM_API_KEY"), @@ -1907,6 +1980,13 @@ mod tests { env::remove_var("NOVITA_BASE_URL"); env::remove_var("FIREWORKS_API_KEY"); env::remove_var("FIREWORKS_BASE_URL"); + env::remove_var("MOONSHOT_API_KEY"); + env::remove_var("MOONSHOT_BASE_URL"); + env::remove_var("MOONSHOT_MODEL"); + env::remove_var("KIMI_API_KEY"); + env::remove_var("KIMI_BASE_URL"); + env::remove_var("KIMI_MODEL"); + env::remove_var("KIMI_MODEL_NAME"); env::remove_var("SGLANG_API_KEY"); env::remove_var("SGLANG_BASE_URL"); env::remove_var("VLLM_API_KEY"); @@ -1954,6 +2034,13 @@ mod tests { Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take()); Self::restore_var("FIREWORKS_API_KEY", self.fireworks_api_key.take()); Self::restore_var("FIREWORKS_BASE_URL", self.fireworks_base_url.take()); + Self::restore_var("MOONSHOT_API_KEY", self.moonshot_api_key.take()); + Self::restore_var("MOONSHOT_BASE_URL", self.moonshot_base_url.take()); + Self::restore_var("MOONSHOT_MODEL", self.moonshot_model.take()); + Self::restore_var("KIMI_API_KEY", self.kimi_api_key.take()); + Self::restore_var("KIMI_BASE_URL", self.kimi_base_url.take()); + Self::restore_var("KIMI_MODEL", self.kimi_model.take()); + Self::restore_var("KIMI_MODEL_NAME", self.kimi_model_name.take()); Self::restore_var("SGLANG_API_KEY", self.sglang_api_key.take()); Self::restore_var("SGLANG_BASE_URL", self.sglang_base_url.take()); Self::restore_var("VLLM_API_KEY", self.vllm_api_key.take()); @@ -2356,6 +2443,11 @@ mod tests { ProviderKind::parse("fireworks-ai"), Some(ProviderKind::Fireworks) ); + assert_eq!(ProviderKind::parse("kimi"), Some(ProviderKind::Moonshot)); + assert_eq!( + ProviderKind::parse("moonshot-ai"), + Some(ProviderKind::Moonshot) + ); assert_eq!(ProviderKind::parse("sg-lang"), Some(ProviderKind::Sglang)); assert_eq!(ProviderKind::parse("v-llm"), Some(ProviderKind::Vllm)); assert_eq!(ProviderKind::parse("vllm"), Some(ProviderKind::Vllm)); @@ -2442,6 +2534,42 @@ mod tests { assert_eq!(resolved.model, DEFAULT_FIREWORKS_MODEL); } + #[test] + fn moonshot_provider_defaults_to_kimi_k2() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::Moonshot, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.base_url, DEFAULT_MOONSHOT_BASE_URL); + assert_eq!(resolved.model, DEFAULT_MOONSHOT_MODEL); + } + + #[test] + fn moonshot_kimi_oauth_uses_kimi_code_endpoint_and_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml { + provider: ProviderKind::Moonshot, + ..ConfigToml::default() + }; + config.providers.moonshot.auth_mode = Some("kimi_oauth".to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.auth_mode.as_deref(), Some("kimi_oauth")); + assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL); + assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL); + assert_eq!(resolved.api_key, None); + assert_eq!(resolved.api_key_source, None); + } + #[test] fn wanjie_ark_provider_defaults_to_openai_compatible_endpoint_and_model() { let _lock = env_lock(); @@ -2556,6 +2684,25 @@ mod tests { assert_eq!(store.gets.lock().unwrap().as_slice(), ["ollama"]); } + #[test] + fn moonshot_api_key_mode_can_use_secret_store_by_default() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let store = Arc::new(RecordingSecretsStore::with_value("secret-store-key")); + let secrets = Secrets::new(store.clone()); + let config = ConfigToml { + provider: ProviderKind::Moonshot, + ..ConfigToml::default() + }; + + let resolved = + config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets); + + assert_eq!(resolved.api_key.as_deref(), Some("secret-store-key")); + assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring)); + assert_eq!(store.gets.lock().unwrap().as_slice(), ["moonshot"]); + } + #[test] fn loopback_custom_deepseek_base_url_does_not_probe_secret_store_by_default() { let _lock = env_lock(); diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs index f2616391..0254aa61 100644 --- a/crates/secrets/src/lib.rs +++ b/crates/secrets/src/lib.rs @@ -535,6 +535,7 @@ pub fn env_for(name: &str) -> Option { &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"] } "fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"], + "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"], "sglang" | "sg-lang" => &["SGLANG_API_KEY"], "vllm" | "v-llm" => &["VLLM_API_KEY"], "ollama" | "ollama-local" => &["OLLAMA_API_KEY"], diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index f78b57a4..63720042 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -45,7 +45,7 @@ fd-lock = "4.0.4" futures-util = "0.3.31" ratatui = "0.30" regex = "1.11" -reqwest = { version = "0.13.1", default-features = false, features = ["blocking", "json", "stream", "multipart", "rustls", "http2", "gzip", "brotli"] } +reqwest = { version = "0.13.1", default-features = false, features = ["blocking", "json", "stream", "multipart", "form", "rustls", "http2", "gzip", "brotli"] } similar = "2" rustyline = "15.0.0" serde = { version = "1.0.228", features = ["derive"] } diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 8ecd3e4c..570b2762 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -904,6 +904,7 @@ pub(super) fn apply_reasoning_effort( ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::WanjieArk + | ApiProvider::Moonshot | ApiProvider::Ollama => {} ApiProvider::NvidiaNim => { body["chat_template_kwargs"] = json!({ @@ -941,6 +942,7 @@ pub(super) fn apply_reasoning_effort( ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::WanjieArk + | ApiProvider::Moonshot | ApiProvider::Ollama => {} ApiProvider::NvidiaNim => { body["chat_template_kwargs"] = json!({ @@ -970,6 +972,7 @@ pub(super) fn apply_reasoning_effort( ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::WanjieArk + | ApiProvider::Moonshot | ApiProvider::Ollama => {} ApiProvider::NvidiaNim => { body["chat_template_kwargs"] = json!({ diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index b4171255..12926b70 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -6,6 +6,7 @@ use std::fs; #[cfg(unix)] use std::io::Write as _; use std::path::{Path, PathBuf}; +use std::time::Duration; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; @@ -50,6 +51,10 @@ pub const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash"; pub const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/v1"; pub const DEFAULT_FIREWORKS_MODEL: &str = "accounts/fireworks/models/deepseek-v4-pro"; pub const DEFAULT_FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1"; +pub const DEFAULT_MOONSHOT_MODEL: &str = "kimi-k2.6"; +pub const DEFAULT_MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1"; +pub const DEFAULT_KIMI_CODE_MODEL: &str = "kimi-for-coding"; +pub const DEFAULT_KIMI_CODE_BASE_URL: &str = "https://api.kimi.com/coding/v1"; pub const DEFAULT_SGLANG_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro"; pub const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash"; pub const DEFAULT_SGLANG_BASE_URL: &str = "http://localhost:30000/v1"; @@ -88,6 +93,7 @@ pub enum ApiProvider { Openrouter, Novita, Fireworks, + Moonshot, Sglang, Vllm, Ollama, @@ -109,6 +115,7 @@ impl ApiProvider { "openrouter" | "open_router" => Some(Self::Openrouter), "novita" => Some(Self::Novita), "fireworks" | "fireworks-ai" => Some(Self::Fireworks), + "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => Some(Self::Moonshot), "sglang" | "sg-lang" => Some(Self::Sglang), "vllm" | "v-llm" => Some(Self::Vllm), "ollama" | "ollama-local" => Some(Self::Ollama), @@ -128,6 +135,7 @@ impl ApiProvider { Self::Openrouter => "openrouter", Self::Novita => "novita", Self::Fireworks => "fireworks", + Self::Moonshot => "moonshot", Self::Sglang => "sglang", Self::Vllm => "vllm", Self::Ollama => "ollama", @@ -147,6 +155,7 @@ impl ApiProvider { Self::Openrouter => "OpenRouter", Self::Novita => "Novita AI", Self::Fireworks => "Fireworks AI", + Self::Moonshot => "Moonshot/Kimi", Self::Sglang => "SGLang", Self::Vllm => "vLLM", Self::Ollama => "Ollama", @@ -165,6 +174,7 @@ impl ApiProvider { Self::Openrouter, Self::Novita, Self::Fireworks, + Self::Moonshot, Self::Sglang, Self::Vllm, Self::Ollama, @@ -233,7 +243,10 @@ pub enum RequestPayloadMode { /// in the API payload (after normalization / provider-specific mapping). #[must_use] pub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> ProviderCapability { - if matches!(provider, ApiProvider::Openai | ApiProvider::Atlascloud) { + if matches!( + provider, + ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Moonshot + ) { return ProviderCapability { provider, resolved_model: resolved_model.to_string(), @@ -420,6 +433,7 @@ pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'stati ApiProvider::Openrouter => vec![DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_FLASH_MODEL], ApiProvider::Novita => vec![DEFAULT_NOVITA_MODEL, DEFAULT_NOVITA_FLASH_MODEL], ApiProvider::Fireworks => vec![DEFAULT_FIREWORKS_MODEL], + ApiProvider::Moonshot => vec![DEFAULT_MOONSHOT_MODEL], ApiProvider::WanjieArk => vec![DEFAULT_WANJIE_ARK_MODEL], ApiProvider::Sglang => vec![DEFAULT_SGLANG_MODEL, DEFAULT_SGLANG_FLASH_MODEL], ApiProvider::Vllm => vec![DEFAULT_VLLM_MODEL, DEFAULT_VLLM_FLASH_MODEL], @@ -922,6 +936,7 @@ pub struct Config { /// Optional extra HTTP headers sent to model API requests. pub http_headers: Option>, pub default_text_model: Option, + pub auth_mode: Option, /// DeepSeek reasoning-effort tier: `"off" | "low" | "medium" | "high" | "max"`. /// Defaults to `"max"` at runtime if unset. pub reasoning_effort: Option, @@ -1209,6 +1224,7 @@ pub struct ProviderConfig { pub api_key: Option, pub base_url: Option, pub model: Option, + pub auth_mode: Option, pub http_headers: Option>, } @@ -1233,6 +1249,8 @@ pub struct ProvidersConfig { #[serde(default)] pub fireworks: ProviderConfig, #[serde(default)] + pub moonshot: ProviderConfig, + #[serde(default)] pub sglang: ProviderConfig, #[serde(default)] pub vllm: ProviderConfig, @@ -1343,6 +1361,7 @@ impl Config { ApiProvider::Openrouter => "providers.openrouter", ApiProvider::Novita => "providers.novita", ApiProvider::Fireworks => "providers.fireworks", + ApiProvider::Moonshot => "providers.moonshot", ApiProvider::Sglang => "providers.sglang", ApiProvider::Vllm => "providers.vllm", ApiProvider::Ollama => "providers.ollama", @@ -1484,6 +1503,7 @@ impl Config { ApiProvider::Openrouter => &providers.openrouter, ApiProvider::Novita => &providers.novita, ApiProvider::Fireworks => &providers.fireworks, + ApiProvider::Moonshot => &providers.moonshot, ApiProvider::Sglang => &providers.sglang, ApiProvider::Vllm => &providers.vllm, ApiProvider::Ollama => &providers.ollama, @@ -1550,6 +1570,13 @@ impl Config { { return model_for_provider(provider, normalized); } + if provider == ApiProvider::Moonshot + && self + .provider_config() + .is_some_and(provider_config_uses_kimi_oauth) + { + return DEFAULT_KIMI_CODE_MODEL.to_string(); + } match provider { ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL, @@ -1560,6 +1587,7 @@ impl Config { ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL, ApiProvider::Novita => DEFAULT_NOVITA_MODEL, ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL, + ApiProvider::Moonshot => DEFAULT_MOONSHOT_MODEL, ApiProvider::Sglang => DEFAULT_SGLANG_MODEL, ApiProvider::Vllm => DEFAULT_VLLM_MODEL, ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL, @@ -1591,6 +1619,7 @@ impl Config { | ApiProvider::Openrouter | ApiProvider::Novita | ApiProvider::Fireworks + | ApiProvider::Moonshot | ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => None, @@ -1606,6 +1635,16 @@ impl Config { ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL, ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL, ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL, + ApiProvider::Moonshot => { + if self + .provider_config() + .is_some_and(provider_config_uses_kimi_oauth) + { + DEFAULT_KIMI_CODE_BASE_URL + } else { + DEFAULT_MOONSHOT_BASE_URL + } + } ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL, ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL, ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL, @@ -1639,6 +1678,7 @@ impl Config { ApiProvider::Openrouter => "openrouter", ApiProvider::Novita => "novita", ApiProvider::Fireworks => "fireworks", + ApiProvider::Moonshot => "moonshot", ApiProvider::Sglang => "sglang", ApiProvider::Vllm => "vllm", ApiProvider::Ollama => "ollama", @@ -1655,6 +1695,14 @@ impl Config { return Ok(configured.clone()); } + if provider == ApiProvider::Moonshot + && self + .provider_config_for(provider) + .is_some_and(provider_config_uses_kimi_oauth) + { + return kimi_cli_oauth_access_token(); + } + // 1. Config file (provider-scoped slot). This intentionally wins // over ambient env so `codewhale auth set` fixes stale shell exports. if let Some(configured) = self @@ -1721,6 +1769,11 @@ impl Config { "Fireworks AI API key not found. Run 'codewhale auth set --provider fireworks', \ set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml." ), + ApiProvider::Moonshot => anyhow::bail!( + "Moonshot/Kimi API key not found. Run 'codewhale auth set --provider moonshot', \ + set MOONSHOT_API_KEY/KIMI_API_KEY, add [providers.moonshot] api_key, \ + or run `kimi login` and set [providers.moonshot] auth_mode = \"kimi_oauth\"." + ), // Self-hosted deployments commonly run without auth on localhost. // Return an empty key and let the client omit the Authorization header. ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()), @@ -2262,6 +2315,13 @@ fn apply_env_overrides(config: &mut Config) { .fireworks .base_url = Some(value); } + ApiProvider::Moonshot => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .moonshot + .base_url = Some(value); + } ApiProvider::Sglang => { config .providers @@ -2368,6 +2428,17 @@ fn apply_env_overrides(config: &mut Config) { .fireworks .base_url = Some(value); } + if matches!(config.api_provider(), ApiProvider::Moonshot) + && let Ok(value) = + std::env::var("MOONSHOT_BASE_URL").or_else(|_| std::env::var("KIMI_BASE_URL")) + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .moonshot + .base_url = Some(value); + } if matches!(config.api_provider(), ApiProvider::Sglang) && let Ok(value) = std::env::var("SGLANG_BASE_URL") && !value.trim().is_empty() @@ -2410,6 +2481,7 @@ fn apply_env_overrides(config: &mut Config) { ApiProvider::Openrouter => &mut providers.openrouter, ApiProvider::Novita => &mut providers.novita, ApiProvider::Fireworks => &mut providers.fireworks, + ApiProvider::Moonshot => &mut providers.moonshot, ApiProvider::Sglang => &mut providers.sglang, ApiProvider::Vllm => &mut providers.vllm, ApiProvider::Ollama => &mut providers.ollama, @@ -2468,6 +2540,17 @@ fn apply_env_overrides(config: &mut Config) { .wanjie_ark .model = Some(value); } + if matches!(config.api_provider(), ApiProvider::Moonshot) + && let Ok(value) = std::env::var("MOONSHOT_MODEL") + .or_else(|_| std::env::var("KIMI_MODEL_NAME")) + .or_else(|_| std::env::var("KIMI_MODEL")) + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .moonshot + .model = Some(value); + } if let Ok(value) = std::env::var("DEEPSEEK_MODEL").or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL")) { @@ -2497,6 +2580,7 @@ fn apply_env_overrides(config: &mut Config) { ApiProvider::Openrouter => &mut providers.openrouter, ApiProvider::Novita => &mut providers.novita, ApiProvider::Fireworks => &mut providers.fireworks, + ApiProvider::Moonshot => &mut providers.moonshot, ApiProvider::Sglang => &mut providers.sglang, ApiProvider::Vllm => &mut providers.vllm, ApiProvider::Ollama => &mut providers.ollama, @@ -2724,6 +2808,12 @@ fn normalize_model_config(config: &mut Config) { { providers.fireworks.model = Some(normalized); } + if let Some(model) = providers.moonshot.model.as_deref() + && !provider_entry_uses_custom_base_url(ApiProvider::Moonshot, &providers.moonshot) + && let Some(normalized) = normalize_model_for_provider(ApiProvider::Moonshot, model) + { + providers.moonshot.model = Some(normalized); + } if let Some(model) = providers.sglang.model.as_deref() && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang) && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model) @@ -2752,6 +2842,7 @@ pub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool { ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::WanjieArk + | ApiProvider::Moonshot | ApiProvider::Ollama ) } @@ -2774,6 +2865,7 @@ fn default_base_url_for_provider(provider: ApiProvider) -> &'static str { ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL, ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL, ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL, + ApiProvider::Moonshot => DEFAULT_MOONSHOT_BASE_URL, ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL, ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL, ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL, @@ -2788,6 +2880,27 @@ fn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &st base_url_is_custom_for_provider(provider, base_url) } +fn provider_config_uses_kimi_oauth(config: &ProviderConfig) -> bool { + config + .auth_mode + .as_deref() + .is_some_and(auth_mode_uses_kimi_oauth) +} + +fn auth_mode_uses_kimi_oauth(mode: &str) -> bool { + matches!( + normalize_auth_mode(mode).as_str(), + "kimi" | "kimi_oauth" | "kimi_cli" | "oauth" + ) +} + +fn normalize_auth_mode(mode: &str) -> String { + mode.trim() + .to_ascii_lowercase() + .replace('-', "_") + .replace(' ', "_") +} + fn base_url_uses_local_host(base_url: &str) -> bool { let Some(host) = base_url_host(base_url) else { return false; @@ -2902,6 +3015,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config { base_url: override_cfg.base_url.or(base.base_url), http_headers: override_cfg.http_headers.or(base.http_headers), default_text_model: override_cfg.default_text_model.or(base.default_text_model), + auth_mode: override_cfg.auth_mode.or(base.auth_mode), reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort), tools_file: override_cfg.tools_file.or(base.tools_file), skills_dir: override_cfg.skills_dir.or(base.skills_dir), @@ -2979,6 +3093,7 @@ fn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> api_key: override_cfg.api_key.or(base.api_key), base_url: override_cfg.base_url.or(base.base_url), model: override_cfg.model.or(base.model), + auth_mode: override_cfg.auth_mode.or(base.auth_mode), http_headers: override_cfg.http_headers.or(base.http_headers), } } @@ -3001,6 +3116,7 @@ fn merge_providers( openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter), novita: merge_provider_config(base.novita, override_cfg.novita), fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks), + moonshot: merge_provider_config(base.moonshot, override_cfg.moonshot), sglang: merge_provider_config(base.sglang, override_cfg.sglang), vllm: merge_provider_config(base.vllm, override_cfg.vllm), ollama: merge_provider_config(base.ollama, override_cfg.ollama), @@ -3373,6 +3489,14 @@ pub fn has_api_key(config: &Config) -> bool { pub fn active_provider_has_config_api_key(config: &Config) -> bool { let provider = config.api_provider(); + if provider == ApiProvider::Moonshot + && config + .provider_config_for(provider) + .is_some_and(provider_config_uses_kimi_oauth) + { + return kimi_cli_credentials_present(); + } + if config .provider_config_for(provider) .and_then(|entry| entry.api_key.as_ref()) @@ -3414,6 +3538,10 @@ pub fn active_provider_has_env_api_key(config: &Config) -> bool { ApiProvider::Fireworks => { std::env::var("FIREWORKS_API_KEY").is_ok_and(|k| !k.trim().is_empty()) } + ApiProvider::Moonshot => { + std::env::var("MOONSHOT_API_KEY").is_ok_and(|k| !k.trim().is_empty()) + || std::env::var("KIMI_API_KEY").is_ok_and(|k| !k.trim().is_empty()) + } ApiProvider::Sglang => std::env::var("SGLANG_API_KEY").is_ok_and(|k| !k.trim().is_empty()), ApiProvider::Vllm => std::env::var("VLLM_API_KEY").is_ok_and(|k| !k.trim().is_empty()), ApiProvider::Ollama => std::env::var("OLLAMA_API_KEY").is_ok_and(|k| !k.trim().is_empty()), @@ -3439,6 +3567,7 @@ pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool { ApiProvider::Openrouter => "OPENROUTER_API_KEY", ApiProvider::Novita => "NOVITA_API_KEY", ApiProvider::Fireworks => "FIREWORKS_API_KEY", + ApiProvider::Moonshot => "MOONSHOT_API_KEY", ApiProvider::Sglang => "SGLANG_API_KEY", ApiProvider::Vllm => "VLLM_API_KEY", ApiProvider::Ollama => "OLLAMA_API_KEY", @@ -3457,6 +3586,19 @@ pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool { { return true; } + if matches!(provider, ApiProvider::Moonshot) + && std::env::var("KIMI_API_KEY").is_ok_and(|k| !k.trim().is_empty()) + { + return true; + } + + if provider == ApiProvider::Moonshot + && config + .provider_config_for(provider) + .is_some_and(provider_config_uses_kimi_oauth) + { + return kimi_cli_credentials_present(); + } // Self-hosted providers typically run without authentication. if matches!( @@ -3519,6 +3661,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result ApiProvider::Openrouter => "providers.openrouter", ApiProvider::Novita => "providers.novita", ApiProvider::Fireworks => "providers.fireworks", + ApiProvider::Moonshot => "providers.moonshot", ApiProvider::Sglang => "providers.sglang", ApiProvider::Vllm => "providers.vllm", ApiProvider::Ollama => "providers.ollama", @@ -3555,6 +3698,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result ApiProvider::Openrouter => "openrouter", ApiProvider::Novita => "novita", ApiProvider::Fireworks => "fireworks", + ApiProvider::Moonshot => "moonshot", ApiProvider::Sglang => "sglang", ApiProvider::Vllm => "vllm", ApiProvider::Ollama => "ollama", @@ -3584,6 +3728,215 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result Ok(config_path) } +pub fn save_provider_auth_mode_for(provider: ApiProvider, auth_mode: &str) -> Result { + let config_path = default_config_path() + .context("Failed to resolve config path: home directory not found.")?; + ensure_parent_dir(&config_path)?; + + let mut doc: toml::Value = if config_path.exists() { + let raw = fs::read_to_string(&config_path)?; + toml::from_str(&raw) + .with_context(|| format!("Failed to parse config at {}", config_path.display()))? + } else { + toml::Value::Table(toml::value::Table::new()) + }; + + let table = doc + .as_table_mut() + .context("Config root must be a TOML table.")?; + let providers = table + .entry("providers".to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())) + .as_table_mut() + .context("`providers` must be a table.")?; + let key_inside = provider_config_key(provider).context("provider auth mode key")?; + let entry = providers + .entry(key_inside.to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())) + .as_table_mut() + .with_context(|| format!("`providers.{key_inside}` must be a table."))?; + entry.insert( + "auth_mode".to_string(), + toml::Value::String(auth_mode.to_string()), + ); + + let serialized = toml::to_string_pretty(&doc).context("failed to serialize updated config")?; + write_config_file_secure(&config_path, &serialized) + .with_context(|| format!("Failed to write config to {}", config_path.display()))?; + log_sensitive_event( + "credential.auth_mode.set", + json!({ + "backend": "config_file", + "provider": provider.as_str(), + "auth_mode": auth_mode, + "config_path": config_path.display().to_string(), + }), + ); + Ok(config_path) +} + +fn provider_config_key(provider: ApiProvider) -> Result<&'static str> { + match provider { + ApiProvider::Deepseek | ApiProvider::DeepseekCN => { + anyhow::bail!("DeepSeek stores auth at the root config level") + } + ApiProvider::NvidiaNim => Ok("nvidia_nim"), + ApiProvider::Openai => Ok("openai"), + ApiProvider::Atlascloud => Ok("atlascloud"), + ApiProvider::WanjieArk => Ok("wanjie_ark"), + ApiProvider::Openrouter => Ok("openrouter"), + ApiProvider::Novita => Ok("novita"), + ApiProvider::Fireworks => Ok("fireworks"), + ApiProvider::Moonshot => Ok("moonshot"), + ApiProvider::Sglang => Ok("sglang"), + ApiProvider::Vllm => Ok("vllm"), + ApiProvider::Ollama => Ok("ollama"), + } +} + +const KIMI_CODE_CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098"; +const KIMI_CODE_CREDENTIAL_FILE: &str = "kimi-code.json"; + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct KimiOAuthCredential { + access_token: Option, + refresh_token: Option, + expires_at: Option, + expires_in: Option, + scope: Option, + token_type: Option, +} + +fn kimi_cli_oauth_access_token() -> Result { + let path = kimi_cli_oauth_credentials_path()?; + let raw = fs::read_to_string(&path).with_context(|| { + format!( + "Kimi OAuth credentials not found at {}. Run `kimi login`, then set \ + [providers.moonshot] auth_mode = \"kimi_oauth\".", + path.display() + ) + })?; + let mut credential: KimiOAuthCredential = + serde_json::from_str(&raw).context("Failed to parse Kimi OAuth credentials")?; + + if kimi_oauth_access_token_is_fresh(&credential) { + return credential + .access_token + .filter(|token| !token.trim().is_empty()) + .context("Kimi OAuth access token is empty"); + } + + let refresh_token = credential + .refresh_token + .as_deref() + .filter(|token| !token.trim().is_empty()) + .context("Kimi OAuth refresh token is empty. Run `kimi login` again.")?; + credential = refresh_kimi_oauth_token(refresh_token)?; + write_kimi_oauth_credential(&path, &credential)?; + credential + .access_token + .filter(|token| !token.trim().is_empty()) + .context("Kimi OAuth refresh returned an empty access token") +} + +fn kimi_oauth_access_token_is_fresh(credential: &KimiOAuthCredential) -> bool { + let Some(now) = now_unix_secs() else { + return false; + }; + + credential + .access_token + .as_deref() + .is_some_and(|token| !token.trim().is_empty()) + && credential + .expires_at + .is_some_and(|expires_at| expires_at - now > 60.0) +} + +fn refresh_kimi_oauth_token(refresh_token: &str) -> Result { + let oauth_host = std::env::var("KIMI_CODE_OAUTH_HOST") + .or_else(|_| std::env::var("KIMI_OAUTH_HOST")) + .unwrap_or_else(|_| "https://auth.kimi.com".to_string()); + let url = format!("{}/api/oauth/token", oauth_host.trim_end_matches('/')); + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .context("Failed to build Kimi OAuth refresh client")?; + let params = [ + ("client_id", KIMI_CODE_CLIENT_ID), + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ]; + let response = client + .post(url) + .header("X-Msh-Platform", "kimi_cli") + .header("X-Msh-Version", env!("CARGO_PKG_VERSION")) + .form(¶ms) + .send() + .context("Kimi OAuth refresh request failed")?; + let status = response.status(); + if !status.is_success() { + anyhow::bail!("Kimi OAuth refresh failed with HTTP {status}. Run `kimi login` again."); + } + + let mut refreshed: KimiOAuthCredential = response + .json() + .context("Failed to parse Kimi OAuth refresh response")?; + if let Some(expires_in) = refreshed.expires_in + && let Some(now) = now_unix_secs() + { + refreshed.expires_at = Some(now + expires_in); + } + Ok(refreshed) +} + +fn kimi_cli_oauth_credentials_path() -> Result { + let share_dir = std::env::var("KIMI_SHARE_DIR") + .map(PathBuf::from) + .or_else(|_| { + effective_home_dir() + .map(|home| home.join(".kimi")) + .ok_or(std::env::VarError::NotPresent) + }) + .context("Failed to resolve Kimi share directory")?; + Ok(share_dir + .join("credentials") + .join(KIMI_CODE_CREDENTIAL_FILE)) +} + +fn write_kimi_oauth_credential(path: &Path, credential: &KimiOAuthCredential) -> Result<()> { + let serialized = serde_json::to_vec_pretty(credential) + .context("Failed to serialize Kimi OAuth credentials")?; + crate::utils::write_atomic(path, &serialized).with_context(|| { + format!( + "Failed to write Kimi OAuth credentials to {}", + path.display() + ) + })?; + #[cfg(unix)] + if let Err(err) = fs::set_permissions(path, fs::Permissions::from_mode(0o600)) { + tracing::warn!( + target: "codewhale::config", + path = %path.display(), + error = %err, + "could not enforce 0o600 on Kimi OAuth credentials; relying on host ACLs" + ); + } + Ok(()) +} + +fn now_unix_secs() -> Option { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs_f64()) + .ok() +} + +#[must_use] +pub fn kimi_cli_credentials_present() -> bool { + kimi_cli_oauth_credentials_path().is_ok_and(|path| path.exists()) +} + /// Clear the API key from config-file storage. /// /// `/logout` calls this to wipe credentials so the next request can't @@ -3735,6 +4088,16 @@ mod tests { novita_base_url: Option, fireworks_api_key: Option, fireworks_base_url: Option, + moonshot_api_key: Option, + moonshot_base_url: Option, + moonshot_model: Option, + kimi_api_key: Option, + kimi_base_url: Option, + kimi_model: Option, + kimi_model_name: Option, + kimi_share_dir: Option, + kimi_code_oauth_host: Option, + kimi_oauth_host: Option, sglang_api_key: Option, sglang_base_url: Option, sglang_model: Option, @@ -3787,6 +4150,16 @@ mod tests { let novita_base_url_prev = env::var_os("NOVITA_BASE_URL"); let fireworks_api_key_prev = env::var_os("FIREWORKS_API_KEY"); let fireworks_base_url_prev = env::var_os("FIREWORKS_BASE_URL"); + let moonshot_api_key_prev = env::var_os("MOONSHOT_API_KEY"); + let moonshot_base_url_prev = env::var_os("MOONSHOT_BASE_URL"); + let moonshot_model_prev = env::var_os("MOONSHOT_MODEL"); + let kimi_api_key_prev = env::var_os("KIMI_API_KEY"); + let kimi_base_url_prev = env::var_os("KIMI_BASE_URL"); + let kimi_model_prev = env::var_os("KIMI_MODEL"); + let kimi_model_name_prev = env::var_os("KIMI_MODEL_NAME"); + let kimi_share_dir_prev = env::var_os("KIMI_SHARE_DIR"); + let kimi_code_oauth_host_prev = env::var_os("KIMI_CODE_OAUTH_HOST"); + let kimi_oauth_host_prev = env::var_os("KIMI_OAUTH_HOST"); let sglang_api_key_prev = env::var_os("SGLANG_API_KEY"); let sglang_base_url_prev = env::var_os("SGLANG_BASE_URL"); let sglang_model_prev = env::var_os("SGLANG_MODEL"); @@ -3834,6 +4207,16 @@ mod tests { env::remove_var("NOVITA_BASE_URL"); env::remove_var("FIREWORKS_API_KEY"); env::remove_var("FIREWORKS_BASE_URL"); + env::remove_var("MOONSHOT_API_KEY"); + env::remove_var("MOONSHOT_BASE_URL"); + env::remove_var("MOONSHOT_MODEL"); + env::remove_var("KIMI_API_KEY"); + env::remove_var("KIMI_BASE_URL"); + env::remove_var("KIMI_MODEL"); + env::remove_var("KIMI_MODEL_NAME"); + env::remove_var("KIMI_SHARE_DIR"); + env::remove_var("KIMI_CODE_OAUTH_HOST"); + env::remove_var("KIMI_OAUTH_HOST"); env::remove_var("SGLANG_API_KEY"); env::remove_var("SGLANG_BASE_URL"); env::remove_var("SGLANG_MODEL"); @@ -3881,6 +4264,16 @@ mod tests { novita_base_url: novita_base_url_prev, fireworks_api_key: fireworks_api_key_prev, fireworks_base_url: fireworks_base_url_prev, + moonshot_api_key: moonshot_api_key_prev, + moonshot_base_url: moonshot_base_url_prev, + moonshot_model: moonshot_model_prev, + kimi_api_key: kimi_api_key_prev, + kimi_base_url: kimi_base_url_prev, + kimi_model: kimi_model_prev, + kimi_model_name: kimi_model_name_prev, + kimi_share_dir: kimi_share_dir_prev, + kimi_code_oauth_host: kimi_code_oauth_host_prev, + kimi_oauth_host: kimi_oauth_host_prev, sglang_api_key: sglang_api_key_prev, sglang_base_url: sglang_base_url_prev, sglang_model: sglang_model_prev, @@ -3937,6 +4330,16 @@ mod tests { Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take()); Self::restore_var("FIREWORKS_API_KEY", self.fireworks_api_key.take()); Self::restore_var("FIREWORKS_BASE_URL", self.fireworks_base_url.take()); + Self::restore_var("MOONSHOT_API_KEY", self.moonshot_api_key.take()); + Self::restore_var("MOONSHOT_BASE_URL", self.moonshot_base_url.take()); + Self::restore_var("MOONSHOT_MODEL", self.moonshot_model.take()); + Self::restore_var("KIMI_API_KEY", self.kimi_api_key.take()); + Self::restore_var("KIMI_BASE_URL", self.kimi_base_url.take()); + Self::restore_var("KIMI_MODEL", self.kimi_model.take()); + Self::restore_var("KIMI_MODEL_NAME", self.kimi_model_name.take()); + Self::restore_var("KIMI_SHARE_DIR", self.kimi_share_dir.take()); + Self::restore_var("KIMI_CODE_OAUTH_HOST", self.kimi_code_oauth_host.take()); + Self::restore_var("KIMI_OAUTH_HOST", self.kimi_oauth_host.take()); Self::restore_var("SGLANG_API_KEY", self.sglang_api_key.take()); Self::restore_var("SGLANG_BASE_URL", self.sglang_base_url.take()); Self::restore_var("SGLANG_MODEL", self.sglang_model.take()); @@ -4830,6 +5233,14 @@ api_key = "old-openrouter-key" ); } + #[test] + fn model_completion_names_for_moonshot_excludes_oauth_only_kimi_code_model() { + assert_eq!( + model_completion_names_for_provider(ApiProvider::Moonshot), + vec![DEFAULT_MOONSHOT_MODEL] + ); + } + #[test] fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() { assert!(normalize_model_name("gpt-4o").is_none()); @@ -5965,6 +6376,64 @@ api_key = "novita-table-key" Ok(()) } + #[test] + fn moonshot_kimi_oauth_reads_fresh_cli_credential() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-kimi-oauth-key-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + let kimi_share_dir = temp_root.join(".kimi"); + let credential_dir = kimi_share_dir.join("credentials"); + fs::create_dir_all(&credential_dir)?; + unsafe { env::set_var("KIMI_SHARE_DIR", &kimi_share_dir) }; + + let expires_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs_f64() + + 3600.0; + let credential = json!({ + "access_token": "fresh-oauth-token", + "refresh_token": "refresh-token", + "expires_at": expires_at, + "scope": "openid profile email", + "token_type": "Bearer", + }); + fs::write( + credential_dir.join(KIMI_CODE_CREDENTIAL_FILE), + serde_json::to_string(&credential)?, + )?; + + let config_path = temp_root.join(".deepseek").join("config.toml"); + ensure_parent_dir(&config_path)?; + fs::write( + &config_path, + r#"provider = "moonshot" + +[providers.moonshot] +auth_mode = "kimi_oauth" +api_key = "stale-api-key" +"#, + )?; + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Moonshot); + assert_eq!(config.deepseek_base_url(), DEFAULT_KIMI_CODE_BASE_URL); + assert_eq!(config.default_model(), DEFAULT_KIMI_CODE_MODEL); + assert_eq!(config.deepseek_api_key()?, "fresh-oauth-token"); + assert!(has_api_key_for(&config, ApiProvider::Moonshot)); + Ok(()) + } + #[test] fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> { let _lock = lock_test_env(); diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 202cd164..7aad7ae6 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -371,6 +371,7 @@ impl Engine { ApiProvider::Openrouter => "OPENROUTER_API_KEY", ApiProvider::Novita => "NOVITA_API_KEY", ApiProvider::Fireworks => "FIREWORKS_API_KEY", + ApiProvider::Moonshot => "MOONSHOT_API_KEY/KIMI_API_KEY", ApiProvider::Sglang => "SGLANG_API_KEY", ApiProvider::Vllm => "VLLM_API_KEY", ApiProvider::Ollama => "OLLAMA_API_KEY", diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 34cb7fce..c53cdaca 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -1861,6 +1861,10 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { "FIREWORKS_API_KEY", "codewhale auth set --provider fireworks --api-key \"...\"", ), + crate::config::ApiProvider::Moonshot => ( + "MOONSHOT_API_KEY/KIMI_API_KEY", + "codewhale auth set --provider moonshot --api-key \"...\"", + ), crate::config::ApiProvider::Sglang => ( "SGLANG_API_KEY", "codewhale auth set --provider sglang --api-key \"...\"", @@ -1887,6 +1891,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { crate::config::ApiProvider::Openrouter => "openrouter", crate::config::ApiProvider::Novita => "novita", crate::config::ApiProvider::Fireworks => "fireworks", + crate::config::ApiProvider::Moonshot => "moonshot", crate::config::ApiProvider::Sglang => "sglang", crate::config::ApiProvider::Vllm => "vllm", crate::config::ApiProvider::Ollama => "ollama", @@ -2154,6 +2159,11 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt "fireworks", &["FIREWORKS_API_KEY"][..], ), + ( + crate::config::ApiProvider::Moonshot, + "moonshot", + &["MOONSHOT_API_KEY", "KIMI_API_KEY"][..], + ), ( crate::config::ApiProvider::Sglang, "sglang", diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index ecf9f722..b2ac79e6 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -26,7 +26,7 @@ use ratatui::{ widgets::{Block, Borders, Clear, Paragraph, Widget}, }; -use crate::config::{ApiProvider, Config, has_api_key_for}; +use crate::config::{ApiProvider, Config, has_api_key_for, kimi_cli_credentials_present}; use crate::palette; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; @@ -94,6 +94,7 @@ impl ProviderPickerView { ApiProvider::Openrouter => "OPENROUTER_API_KEY", ApiProvider::Novita => "NOVITA_API_KEY", ApiProvider::Fireworks => "FIREWORKS_API_KEY", + ApiProvider::Moonshot => "MOONSHOT_API_KEY / KIMI_API_KEY", ApiProvider::Sglang => "SGLANG_API_KEY", ApiProvider::Vllm => "VLLM_API_KEY", ApiProvider::Ollama => "OLLAMA_API_KEY", @@ -102,6 +103,9 @@ impl ProviderPickerView { fn provider_hint(provider: ApiProvider, has_key: bool) -> String { match provider { + ApiProvider::Moonshot if kimi_cli_credentials_present() => { + "(Kimi CLI OAuth ready)".to_string() + } ApiProvider::Ollama => "self-hosted; defaults to http://localhost:11434".to_string(), ApiProvider::Sglang | ApiProvider::Vllm if has_key => { "(configured; optional key)".to_string() @@ -287,6 +291,10 @@ impl ModalView for ProviderPickerView { let provider = self.selected_provider(); if self.selected_has_key() { ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied { provider }) + } else if provider == ApiProvider::Moonshot && kimi_cli_credentials_present() { + ViewAction::EmitAndClose(ViewEvent::ProviderPickerKimiOAuthEnabled { + provider, + }) } else { self.stage = Stage::KeyEntry; self.api_key_input.clear(); @@ -400,6 +408,7 @@ mod tests { "OpenRouter", "Novita AI", "Fireworks AI", + "Moonshot/Kimi", "SGLang", "vLLM", "Ollama" diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 25e1fdb1..f1ebabfe 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -38,7 +38,10 @@ use crate::automation_manager::{AutomationManager, AutomationSchedulerConfig, sp use crate::client::{DeepSeekClient, build_cache_warmup_request}; use crate::commands; use crate::compaction::estimate_input_tokens_conservative; -use crate::config::{ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL}; +use crate::config::{ + ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL, ProviderConfig, ProvidersConfig, + save_provider_auth_mode_for, +}; use crate::config_ui::{self, ConfigUiMode, WebConfigSession, WebConfigSessionEvent}; use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine}; use crate::core::events::Event as EngineEvent; @@ -5730,6 +5733,7 @@ fn render(f: &mut Frame, app: &mut App) { crate::config::ApiProvider::Openrouter => Some("OR"), crate::config::ApiProvider::Novita => Some("Novita"), crate::config::ApiProvider::Fireworks => Some("Fireworks"), + crate::config::ApiProvider::Moonshot => Some("Kimi"), crate::config::ApiProvider::Sglang => Some("SGLang"), crate::config::ApiProvider::Vllm => Some("vLLM"), crate::config::ApiProvider::Ollama => Some("Ollama"), @@ -6270,6 +6274,17 @@ async fn handle_view_events( ViewEvent::ProviderPickerApiKeySubmitted { provider, api_key } => { apply_provider_picker_api_key(app, engine_handle, config, provider, api_key).await; } + ViewEvent::ProviderPickerKimiOAuthEnabled { provider } => { + apply_provider_picker_auth_mode( + app, + engine_handle, + config, + provider, + "kimi_oauth", + "Linked Kimi CLI OAuth", + ) + .await; + } ViewEvent::ModeSelected { mode } => { let msg = commands::switch_mode(app, mode); app.add_message(HistoryCell::System { content: msg }); @@ -6477,7 +6492,7 @@ async fn apply_provider_picker_api_key( provider: ApiProvider, api_key: String, ) { - use crate::config::{ProviderConfig, ProvidersConfig, save_api_key_for}; + use crate::config::save_api_key_for; match save_api_key_for(provider, &api_key) { Ok(path) => { @@ -6519,6 +6534,7 @@ async fn apply_provider_picker_api_key( ApiProvider::Openrouter => &mut providers.openrouter, ApiProvider::Novita => &mut providers.novita, ApiProvider::Fireworks => &mut providers.fireworks, + ApiProvider::Moonshot => &mut providers.moonshot, ApiProvider::Sglang => &mut providers.sglang, ApiProvider::Vllm => &mut providers.vllm, ApiProvider::Ollama => &mut providers.ollama, @@ -6529,6 +6545,55 @@ async fn apply_provider_picker_api_key( switch_provider(app, engine_handle, config, provider, None).await; } +async fn apply_provider_picker_auth_mode( + app: &mut App, + engine_handle: &mut EngineHandle, + config: &mut Config, + provider: ApiProvider, + auth_mode: &str, + status_prefix: &str, +) { + match save_provider_auth_mode_for(provider, auth_mode) { + Ok(path) => { + set_provider_auth_mode_in_memory(config, provider, auth_mode.to_string()); + app.status_message = Some(format!("{status_prefix}; saved to {}", path.display())); + app.api_key_env_only = false; + } + Err(err) => { + app.add_message(HistoryCell::System { + content: format!( + "Failed to save {} auth mode: {err}\nProvider unchanged.", + provider.as_str() + ), + }); + return; + } + } + + switch_provider(app, engine_handle, config, provider, None).await; +} + +fn set_provider_auth_mode_in_memory(config: &mut Config, provider: ApiProvider, auth_mode: String) { + let providers = config + .providers + .get_or_insert_with(ProvidersConfig::default); + let entry: &mut ProviderConfig = match provider { + ApiProvider::Deepseek | ApiProvider::DeepseekCN => return, + ApiProvider::NvidiaNim => &mut providers.nvidia_nim, + ApiProvider::Openai => &mut providers.openai, + ApiProvider::Atlascloud => &mut providers.atlascloud, + ApiProvider::WanjieArk => &mut providers.wanjie_ark, + ApiProvider::Openrouter => &mut providers.openrouter, + ApiProvider::Novita => &mut providers.novita, + ApiProvider::Fireworks => &mut providers.fireworks, + ApiProvider::Moonshot => &mut providers.moonshot, + ApiProvider::Sglang => &mut providers.sglang, + ApiProvider::Vllm => &mut providers.vllm, + ApiProvider::Ollama => &mut providers.ollama, + }; + entry.auth_mode = Some(auth_mode); +} + fn apply_loaded_session(app: &mut App, config: &Config, session: &SavedSession) -> bool { let (messages, recovered_draft) = recover_interrupted_user_tail(&session.messages); app.api_messages = messages; diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index c50c83c0..e9b91740 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -157,6 +157,11 @@ pub enum ViewEvent { provider: crate::config::ApiProvider, api_key: String, }, + /// Emitted by the `/provider` picker when Kimi CLI OAuth credentials can + /// be reused for Moonshot/Kimi dispatch. + ProviderPickerKimiOAuthEnabled { + provider: crate::config::ApiProvider, + }, /// Emitted by the `/mode` picker when the user chooses a mode. ModeSelected { mode: crate::tui::app::AppMode, From c483cd525d59d144c4e401fdc8681df6f3fb197a Mon Sep 17 00:00:00 2001 From: Reid <61492567+reidliu41@users.noreply.github.com> Date: Tue, 26 May 2026 07:17:31 +0800 Subject: [PATCH 004/283] fix(tui): simplify approval confirmation flow Harvested from PR #2143 by @reidliu41. Co-authored-by: reidliu41 --- crates/tui/src/tui/approval.rs | 219 +++++++------------------- crates/tui/src/tui/ui.rs | 84 +++++++--- crates/tui/src/tui/widgets/mod.rs | 250 +++++++++++------------------- 3 files changed, 199 insertions(+), 354 deletions(-) diff --git a/crates/tui/src/tui/approval.rs b/crates/tui/src/tui/approval.rs index dc7e9cdc..92e3208e 100644 --- a/crates/tui/src/tui/approval.rs +++ b/crates/tui/src/tui/approval.rs @@ -16,10 +16,9 @@ //! `2` / `a` approves for the session. //! - **Destructive** (`RiskLevel::Destructive`) — file writes, shell, //! patches, MCP actions, unclassified tools, and any "fetch arbitrary -//! content" surface. The first approve press *stages* a decision and -//! the second matching press commits — muscle-memory `Enter` cannot -//! accidentally land on an approval. Any non-approve key clears the -//! staging and keeps the user in selection mode. +//! content" surface. The takeover keeps the destructive badge and +//! impact summary visible, then lets `Enter` commit the highlighted +//! option or `y` / `a` / `d` commit directly. //! //! The decision events emitted upstream are unchanged //! (`ViewEvent::ApprovalDecision`), so `ui.rs` and the engine handle @@ -102,8 +101,8 @@ pub enum ToolCategory { /// Stakes-based variant for the takeover modal. /// /// `RiskLevel::Benign` lets a single keystroke commit the approval. -/// `RiskLevel::Destructive` requires an explicit second confirmation -/// keypress so muscle-memory `Enter` never lands on an irreversible op. +/// `RiskLevel::Destructive` keeps stronger warning copy and styling +/// around approvals that can touch files, shell, or remote state. /// /// Routing rules live in [`classify_risk`] — when in doubt, route to /// `Destructive`. @@ -228,13 +227,12 @@ pub fn get_tool_category(name: &str) -> ToolCategory { /// The bias is conservative: a category we don't recognise routes to /// `Destructive`, and any shell command that `command_safety` flags as /// `Dangerous` is forced to `Destructive` even when the rest of the -/// request looks calm. The split lets the modal swap muscle-memory -/// approval for an explicit two-key confirmation on anything that can -/// touch state outside this turn. +/// request looks calm. The split lets the modal render stronger warning +/// copy on anything that can touch state outside this turn. #[must_use] pub fn classify_risk(tool_name: &str, category: ToolCategory, params: &Value) -> RiskLevel { match category { - // Read paths and discovery — never staged. + // Read paths and discovery. ToolCategory::Safe | ToolCategory::McpRead => RiskLevel::Benign, // Query-only network is benign; opening a URL pulls arbitrary // remote content, so it stays destructive. @@ -448,9 +446,7 @@ fn build_impact_summary_zh_hans( } } -/// Indices into the option list shared by both variants. Visible to -/// the widget module so it can render the staged-confirmation banner -/// without re-deriving the variant from the request. +/// Indices into the option list shared by both variants. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ApprovalOption { ApproveOnce, @@ -486,16 +482,6 @@ impl ApprovalOption { ApprovalOption::Abort => ReviewDecision::Abort, } } - - /// Whether this option needs an explicit second-key confirmation in - /// the destructive variant. Deny/Abort are never staged. - fn requires_confirm(self, risk: RiskLevel) -> bool { - matches!(risk, RiskLevel::Destructive) - && matches!( - self, - ApprovalOption::ApproveOnce | ApprovalOption::ApproveAlways - ) - } } /// Approval overlay state managed by the modal view stack @@ -504,10 +490,6 @@ pub struct ApprovalView { request: ApprovalRequest, selected: usize, locale: Locale, - /// When `Some`, the destructive variant has staged this approval and - /// is waiting for the user to press the same key (or `Enter`) again. - /// Any other key clears the staging. - pending_confirm: Option, timeout: Option, requested_at: Instant, /// Whether the approval card is collapsed to a single-line banner. @@ -525,7 +507,6 @@ impl ApprovalView { request, selected: 0, locale, - pending_confirm: None, timeout: None, requested_at: Instant::now(), collapsed: false, @@ -534,22 +515,17 @@ impl ApprovalView { fn select_prev(&mut self) { self.selected = self.selected.saturating_sub(1); - // Moving the selection abandons any staged confirmation; the - // user is reconsidering. - self.pending_confirm = None; } fn select_next(&mut self) { self.selected = (self.selected + 1).min(ApprovalOption::ORDER.len() - 1); - self.pending_confirm = None; } fn current_option(&self) -> ApprovalOption { ApprovalOption::from_index(self.selected) } - /// Test-only accessor — the widget reads decisions through - /// `commit_or_stage` instead of polling. + /// Test-only accessor for the selected option's decision. #[cfg(test)] fn current_decision(&self) -> ReviewDecision { self.current_option().decision() @@ -566,33 +542,13 @@ impl ApprovalView { self.request.risk } - /// The staged option, if any. `None` in the benign variant or when - /// no approve key has been pressed yet. - pub(crate) fn pending_confirm(&self) -> Option { - self.pending_confirm - } - pub(crate) fn locale(&self) -> Locale { self.locale } - /// Try to commit (or stage) the given option respecting the - /// variant's confirmation policy. Returns the action the modal - /// stack should apply. - fn commit_or_stage(&mut self, option: ApprovalOption) -> ViewAction { - if option.requires_confirm(self.request.risk) { - // Two-step destructive flow: first press stages, second - // press of the same option commits. - if self.pending_confirm == Some(option) { - self.pending_confirm = None; - return self.emit_decision(option.decision(), false); - } - self.pending_confirm = Some(option); - self.selected = option.index(); - return ViewAction::None; - } - // Benign variant or non-approve options commit immediately. - self.pending_confirm = None; + /// Commit the given option and close the approval modal. + fn commit_option(&mut self, option: ApprovalOption) -> ViewAction { + self.selected = option.index(); self.emit_decision(option.decision(), false) } @@ -647,31 +603,23 @@ impl ModalView for ApprovalView { self.select_next(); ViewAction::None } - KeyCode::Enter => self.commit_or_stage(self.current_option()), + KeyCode::Enter => self.commit_option(self.current_option()), // Direct shortcuts; '1' / '2' map to the first two options - // so a numeric pad still works for benign approve flows. + // so a numeric pad still works for approve flows. KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('1') => { - self.commit_or_stage(ApprovalOption::ApproveOnce) + self.commit_option(ApprovalOption::ApproveOnce) } KeyCode::Char('a') | KeyCode::Char('A') | KeyCode::Char('2') => { - self.commit_or_stage(ApprovalOption::ApproveAlways) + self.commit_option(ApprovalOption::ApproveAlways) } KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Char('d') | KeyCode::Char('D') - | KeyCode::Char('3') => self.commit_or_stage(ApprovalOption::Deny), - KeyCode::Char('v') | KeyCode::Char('V') => { - self.pending_confirm = None; - self.emit_params_pager() - } + | KeyCode::Char('3') => self.commit_option(ApprovalOption::Deny), + KeyCode::Char('v') | KeyCode::Char('V') => self.emit_params_pager(), KeyCode::Esc => self.emit_decision(ReviewDecision::Abort, false), - _ => { - // Any unrecognised key cancels a staged confirmation — - // the user is no longer aiming at "approve". - self.pending_confirm = None; - ViewAction::None - } + _ => ViewAction::None, } } @@ -1030,13 +978,13 @@ mod tests { #[test] fn risk_query_only_network_is_benign_but_fetch_is_destructive() { - // web_search is read-only enough to skip the two-key dance. + // web_search is read-only enough to use the benign variant. let cat = ToolCategory::Network; assert_eq!( classify_risk("web_search", cat, &json!({"q": "rust"})), RiskLevel::Benign ); - // fetch_url pulls arbitrary remote content; never staged. + // fetch_url pulls arbitrary remote content, so it stays destructive. assert_eq!( classify_risk("fetch_url", cat, &json!({"url": "https://example.com"})), RiskLevel::Destructive @@ -1163,7 +1111,6 @@ mod tests { let view = ApprovalView::new(benign_request()); assert_eq!(view.selected, 0); assert!(view.timeout.is_none()); - assert_eq!(view.pending_confirm(), None); assert_eq!(view.risk(), RiskLevel::Benign); } @@ -1376,7 +1323,7 @@ mod tests { } // ======================================================================== - // ApprovalView Tests — Destructive Variant (two-key confirm) + // ApprovalView Tests — Destructive Variant (one-step approve with warning) // ======================================================================== #[test] @@ -1386,16 +1333,10 @@ mod tests { } #[test] - fn destructive_y_first_press_stages_then_second_commits() { + fn destructive_y_first_press_approves_once() { for code in [KeyCode::Char('y'), KeyCode::Char('Y')] { let mut view = ApprovalView::new(destructive_request()); - // First press stages — no decision emitted yet. - let action = view.handle_key(create_key_event(code)); - assert!(matches!(action, ViewAction::None)); - assert_eq!(view.pending_confirm(), Some(ApprovalOption::ApproveOnce)); - - // Second press of the same key commits. let action = view.handle_key(create_key_event(code)); assert!( matches!( @@ -1411,15 +1352,10 @@ mod tests { } #[test] - fn destructive_enter_first_press_stages_then_second_commits() { + fn destructive_enter_approves_selected_option() { let mut view = ApprovalView::new(destructive_request()); - // Selection starts at ApproveOnce — Enter stages. - let action = view.handle_key(create_key_event(KeyCode::Enter)); - assert!(matches!(action, ViewAction::None)); - assert_eq!(view.pending_confirm(), Some(ApprovalOption::ApproveOnce)); - - // Second Enter on the same selection commits. + // Selection starts at ApproveOnce — Enter commits the selected option. let action = view.handle_key(create_key_event(KeyCode::Enter)); assert!(matches!( action, @@ -1431,39 +1367,33 @@ mod tests { } #[test] - fn destructive_navigation_clears_staged_confirmation() { + fn destructive_navigation_then_enter_commits_highlighted_option() { let mut view = ApprovalView::new(destructive_request()); - view.handle_key(create_key_event(KeyCode::Char('y'))); - assert_eq!(view.pending_confirm(), Some(ApprovalOption::ApproveOnce)); - - // Moving the selection abandons the staging. view.handle_key(create_key_event(KeyCode::Down)); - assert_eq!(view.pending_confirm(), None); + let action = view.handle_key(create_key_event(KeyCode::Enter)); + assert!(matches!( + action, + ViewAction::EmitAndClose(ViewEvent::ApprovalDecision { + decision: ReviewDecision::ApprovedForSession, + .. + }) + )); } #[test] - fn destructive_unrelated_key_clears_staged_confirmation() { + fn destructive_unrelated_key_keeps_modal_open() { let mut view = ApprovalView::new(destructive_request()); - view.handle_key(create_key_event(KeyCode::Char('y'))); - assert_eq!(view.pending_confirm(), Some(ApprovalOption::ApproveOnce)); - - // A key with no mapped action clears the staging. let action = view.handle_key(create_key_event(KeyCode::Char('q'))); assert!(matches!(action, ViewAction::None)); - assert_eq!(view.pending_confirm(), None); } #[test] - fn destructive_a_first_press_stages_then_second_commits_session() { + fn destructive_a_first_press_approves_for_session() { for code in [KeyCode::Char('a'), KeyCode::Char('A')] { let mut view = ApprovalView::new(destructive_request()); - let action = view.handle_key(create_key_event(code)); - assert!(matches!(action, ViewAction::None)); - assert_eq!(view.pending_confirm(), Some(ApprovalOption::ApproveAlways)); - let action = view.handle_key(create_key_event(code)); assert!( matches!( @@ -1479,23 +1409,8 @@ mod tests { } #[test] - fn destructive_y_then_a_does_not_commit_either() { - // Pressing 'y' then 'a' must NOT commit ApproveAlways — the - // second key is a different option, so it re-stages instead. - let mut view = ApprovalView::new(destructive_request()); - - let action = view.handle_key(create_key_event(KeyCode::Char('y'))); - assert!(matches!(action, ViewAction::None)); - assert_eq!(view.pending_confirm(), Some(ApprovalOption::ApproveOnce)); - - let action = view.handle_key(create_key_event(KeyCode::Char('a'))); - assert!(matches!(action, ViewAction::None)); - assert_eq!(view.pending_confirm(), Some(ApprovalOption::ApproveAlways)); - } - - #[test] - fn destructive_deny_does_not_require_confirmation() { - // Deny / Abort skip the two-key dance — the user is bailing. + fn destructive_deny_commits_immediately() { + // Deny commits immediately — the user is rejecting the tool. for code in [ KeyCode::Char('n'), KeyCode::Char('N'), @@ -1520,9 +1435,6 @@ mod tests { #[test] fn destructive_esc_aborts_immediately() { let mut view = ApprovalView::new(destructive_request()); - // Stage something first. - view.handle_key(create_key_event(KeyCode::Char('y'))); - // Esc still aborts in one press. let action = view.handle_key(create_key_event(KeyCode::Esc)); assert!(matches!( action, @@ -1557,20 +1469,21 @@ mod tests { } #[test] - fn render_benign_includes_review_badge_and_one_step_hint() { + fn render_benign_includes_review_badge_and_selection_hint() { let view = ApprovalView::new(benign_request()); let lines = render_lines(&view, 100, 40); let joined = lines.join("\n"); assert!(joined.contains("REVIEW"), "missing REVIEW badge:\n{joined}"); + assert!(joined.contains("Choose"), "benign hint missing:\n{joined}"); assert!( - joined.contains("Single key approves"), - "benign hint missing:\n{joined}" + joined.contains("Enter selected option"), + "benign selection hint missing:\n{joined}" ); assert!(joined.contains("read_file")); } #[test] - fn render_destructive_shows_warning_badge_and_two_step_hint() { + fn render_destructive_shows_warning_badge_and_one_step_hint() { let view = ApprovalView::new(destructive_request()); let lines = render_lines(&view, 100, 40); let joined = lines.join("\n"); @@ -1579,31 +1492,15 @@ mod tests { "missing DESTRUCTIVE badge:\n{joined}" ); assert!( - joined.contains("Two keys to approve"), + joined.contains("Enter selected option"), "destructive hint missing:\n{joined}" ); assert!(joined.contains("write_file")); } - #[test] - fn render_destructive_after_stage_shows_confirm_banner() { - let mut view = ApprovalView::new(destructive_request()); - view.handle_key(create_key_event(KeyCode::Char('y'))); - let lines = render_lines(&view, 100, 40); - let joined = lines.join("\n"); - assert!( - joined.contains("Confirm destructive action"), - "confirm banner missing:\n{joined}" - ); - assert!( - joined.contains("(staged)"), - "stage marker missing:\n{joined}" - ); - } - #[test] fn render_destructive_zh_hans_localizes_security_copy() { - let mut view = ApprovalView::new_for_locale(destructive_request(), Locale::ZhHans); + let view = ApprovalView::new_for_locale(destructive_request(), Locale::ZhHans); let lines = render_lines(&view, 100, 40); let joined = compact_rendered_text(&lines); assert!( @@ -1611,8 +1508,12 @@ mod tests { "missing zh risk badge:\n{joined}" ); assert!( - joined.contains("两次按键确认"), - "missing zh two-step hint:\n{joined}" + joined.contains("选择:"), + "missing zh selection prefix:\n{joined}" + ); + assert!( + joined.contains("Enter执行选中项,或直接按y/a/d"), + "missing zh one-step hint:\n{joined}" ); assert!( joined.contains("文件写入"), @@ -1630,22 +1531,6 @@ mod tests { joined.contains("仅本次批准"), "missing zh approve option:\n{joined}" ); - - view.handle_key(create_key_event(KeyCode::Char('y'))); - let lines = render_lines(&view, 100, 40); - let joined = compact_rendered_text(&lines); - assert!( - joined.contains("确认破坏性操作"), - "missing zh confirm banner:\n{joined}" - ); - assert!( - joined.contains("(待确认)"), - "missing zh staged marker:\n{joined}" - ); - assert!( - joined.contains("Enter或y"), - "missing zh confirm key:\n{joined}" - ); } #[test] diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index f1ebabfe..dffa442c 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -6036,31 +6036,19 @@ async fn handle_view_events( approval_key, approval_grouping_key, } => { - if decision == ReviewDecision::ApprovedForSession { - // Store the tool name (backward compat) and the lossy - // grouping key so later flag variants of the same - // command family are also auto-approved (v0.8.37). - app.approval_session_approved.insert(tool_name.clone()); - app.approval_session_approved - .insert(approval_grouping_key.clone()); - } - - match decision { - ReviewDecision::Approved | ReviewDecision::ApprovedForSession => { - let _ = engine_handle.approve_tool_call(tool_id).await; - } - ReviewDecision::Denied | ReviewDecision::Abort => { - // Cache the denial so the model retry-loop doesn't - // re-prompt for the exact same approval_key (#360). - // Only the key (per-call unique) is stored — NOT - // the tool_name, which would block all future - // invocations of the same tool type (#1377). - if !timed_out { - app.approval_session_denied.insert(approval_key); - } - let _ = engine_handle.deny_tool_call(tool_id).await; - } - } + apply_approval_decision( + app, + engine_handle, + ApprovalDecisionEvent { + tool_id, + tool_name, + decision, + timed_out, + approval_key, + approval_grouping_key, + }, + ) + .await; if timed_out { app.add_message(HistoryCell::System { @@ -6333,6 +6321,52 @@ async fn handle_view_events( Ok(false) } +struct ApprovalDecisionEvent { + tool_id: String, + tool_name: String, + decision: ReviewDecision, + timed_out: bool, + approval_key: String, + approval_grouping_key: String, +} + +async fn apply_approval_decision( + app: &mut App, + engine_handle: &mut EngineHandle, + event: ApprovalDecisionEvent, +) { + if event.decision == ReviewDecision::ApprovedForSession { + // Store the tool name (backward compat) and the lossy grouping key so + // later flag variants of the same command family are also auto-approved + // (v0.8.37). + app.approval_session_approved + .insert(event.tool_name.clone()); + app.approval_session_approved + .insert(event.approval_grouping_key.clone()); + } + + match event.decision { + ReviewDecision::Approved | ReviewDecision::ApprovedForSession => { + let _ = engine_handle.approve_tool_call(event.tool_id).await; + } + ReviewDecision::Denied => { + // Cache the denial so the model retry-loop doesn't re-prompt for + // the exact same approval_key (#360). Only the key (per-call + // unique) is stored — NOT the tool_name, which would block all + // future invocations of the same tool type (#1377). + if !event.timed_out { + app.approval_session_denied.insert(event.approval_key); + } + let _ = engine_handle.deny_tool_call(event.tool_id).await; + } + ReviewDecision::Abort => { + engine_handle.cancel(); + mark_active_turn_cancelled_locally(app); + app.status_message = Some("Request cancelled".to_string()); + } + } +} + fn mark_active_turn_cancelled_locally(app: &mut App) { app.is_loading = false; app.dispatch_started_at = None; diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index a8179769..05ff8a95 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -1015,13 +1015,10 @@ impl Renderable for ComposerWidget<'_> { /// Codex-style full-screen approval takeover (#129). /// -/// The widget reads its mutable state (selected option, staged -/// confirmation) directly from the [`ApprovalView`] so the destructive -/// variant can render its "Press Y again to confirm" banner without -/// touching internal fields. Rendering reflows to fill most of the -/// transcript area instead of a centered popup; on small terminals it -/// falls back to a 65×22 card so existing snapshot tests still see a -/// coherent layout. +/// The widget reads its selected option and locale directly from the +/// [`ApprovalView`]. Rendering reflows to fill most of the transcript +/// area instead of a centered popup; on small terminals it falls back to +/// a 65×22 card so existing snapshot tests still see a coherent layout. pub struct ApprovalWidget<'a> { request: &'a ApprovalRequest, view: &'a ApprovalView, @@ -1038,8 +1035,8 @@ impl<'a> ApprovalWidget<'a> { /// terminal can hold. const APPROVAL_CARD_HORIZONTAL_PAD: u16 = 6; const APPROVAL_CARD_VERTICAL_PAD: u16 = 2; -/// Minimum card height — anything tighter and the destructive variant's -/// confirmation banner overlaps the option list. +/// Minimum card height — anything tighter and the approval controls +/// overlap the option list. const APPROVAL_CARD_MIN_HEIGHT: u16 = 18; /// Minimum card width — anything tighter makes approval copy wrap too /// aggressively on small terminals. @@ -1164,120 +1161,49 @@ impl Renderable for ApprovalWidget<'_> { lines.push(Line::from("")); let options = approval_options_for(risk, locale); - let pending = self.view.pending_confirm(); for (i, opt) in options.iter().enumerate() { let is_selected = i == self.view.selected(); - let staged = pending.is_some_and(|p| p == opt.option); let label_color = if opt.dangerous { palette_colors.accent } else { palette::TEXT_BODY }; - let row_style = if is_selected { - Style::default() - .fg(palette::SELECTION_TEXT) - .bg(palette::SELECTION_BG) - } else { - Style::default() - }; + let option_style = approval_option_style(is_selected, label_color); + let shortcut_style = approval_option_style(is_selected, palette_colors.shortcut); - let mut spans = vec![ + let spans = vec![ Span::raw(" "), Span::styled( format!("[{}] ", opt.key_hint), - Style::default() - .fg(palette_colors.shortcut) - .add_modifier(Modifier::BOLD), + shortcut_style.add_modifier(Modifier::BOLD), ), - Span::styled(opt.label.to_string(), row_style.fg(label_color)), + Span::styled(opt.label.to_string(), option_style), ]; - if staged { - spans.push(Span::raw(" ")); - spans.push(Span::styled( - staged_marker(locale), - Style::default() - .fg(palette_colors.accent) - .add_modifier(Modifier::BOLD), - )); - } lines.push(Line::from(spans)); } - // Variant-specific footer: benign nudges single-key approve; - // destructive shows either the standing prompt or the - // confirmation banner when an approve key has been staged. + // Footer: Enter commits the highlighted row; y/a/d remain direct + // shortcuts for users who do not want to move the selection. lines.push(Line::from("")); - match (risk, pending) { - (RiskLevel::Benign, _) => { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled( - single_key_prefix(locale), - Style::default().fg(palette::TEXT_HINT), - ), - Span::styled( - single_key_value(locale), - Style::default() - .fg(palette_colors.accent) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - footer_controls(locale), - Style::default().fg(palette::TEXT_HINT), - ), - ])); - } - (RiskLevel::Destructive, Some(opt)) => { - let again_key = match opt { - crate::tui::approval::ApprovalOption::ApproveOnce => confirm_key_once(locale), - crate::tui::approval::ApprovalOption::ApproveAlways => { - confirm_key_always(locale) - } - _ => "Enter", - }; - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled( - destructive_confirm_prefix(locale), - Style::default() - .fg(palette_colors.accent) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - again_key.to_string(), - Style::default() - .fg(palette::DEEPSEEK_INK) - .bg(palette_colors.accent) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - destructive_confirm_suffix(locale), - Style::default().fg(palette::TEXT_HINT), - ), - ])); - } - (RiskLevel::Destructive, None) => { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled( - two_key_prefix(locale), - Style::default().fg(palette::TEXT_HINT), - ), - Span::styled( - two_key_value(locale), - Style::default() - .fg(palette_colors.accent) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - footer_controls(locale), - Style::default().fg(palette::TEXT_HINT), - ), - ])); - } - } + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + selection_hint_prefix(locale), + Style::default().fg(palette::TEXT_HINT), + ), + Span::styled( + selection_hint_value(locale), + Style::default() + .fg(palette_colors.accent) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + footer_controls(locale), + Style::default().fg(palette::TEXT_HINT), + ), + ])); let title = format!( " {} {} — {} ", @@ -1375,6 +1301,21 @@ fn approval_palette(risk: RiskLevel) -> ApprovalColors { } } +fn approval_selected_style() -> Style { + Style::default() + .fg(palette::SELECTION_TEXT) + .bg(palette::DEEPSEEK_BLUE) + .add_modifier(Modifier::BOLD) +} + +fn approval_option_style(is_selected: bool, color: Color) -> Style { + if is_selected { + approval_selected_style() + } else { + Style::default().fg(color) + } +} + fn risk_badge_text(risk: RiskLevel, locale: Locale) -> &'static str { match (locale, risk) { (Locale::ZhHans, RiskLevel::Benign) => "审查", @@ -1438,24 +1379,6 @@ fn label_params(locale: Locale) -> &'static str { } } -fn staged_marker(locale: Locale) -> &'static str { - match locale { - Locale::ZhHans => "(待确认)", - _ => "(staged)", - } -} - -fn single_key_prefix(locale: Locale) -> &'static str { - match locale { - Locale::ZhHans => "单键批准:", - _ => "Single key approves: ", - } -} - -fn single_key_value(_locale: Locale) -> &'static str { - "Enter / 1 / y" -} - fn footer_controls(locale: Locale) -> &'static str { match locale { Locale::ZhHans => " · v:完整参数 · Esc:终止", @@ -1463,79 +1386,45 @@ fn footer_controls(locale: Locale) -> &'static str { } } -fn destructive_confirm_prefix(locale: Locale) -> &'static str { +fn selection_hint_prefix(locale: Locale) -> &'static str { match locale { - Locale::ZhHans => "确认破坏性操作:再次按 ", - _ => "Confirm destructive action — press ", + Locale::ZhHans => "选择:", + _ => "Choose: ", } } -fn destructive_confirm_suffix(locale: Locale) -> &'static str { +fn selection_hint_value(locale: Locale) -> &'static str { match locale { - Locale::ZhHans => " 执行;按其他键取消。", - _ => " again to commit, anything else cancels.", - } -} - -fn confirm_key_once(locale: Locale) -> &'static str { - match locale { - Locale::ZhHans => "Enter 或 y", - _ => "Enter or y", - } -} - -fn confirm_key_always(locale: Locale) -> &'static str { - match locale { - Locale::ZhHans => "Enter 或 a", - _ => "Enter or a", - } -} - -fn two_key_prefix(locale: Locale) -> &'static str { - match locale { - Locale::ZhHans => "两次按键确认:", - _ => "Two keys to approve: ", - } -} - -fn two_key_value(locale: Locale) -> &'static str { - match locale { - Locale::ZhHans => "先按 y/a,再按一次 y/a", - _ => "y/a then y/a again", + Locale::ZhHans => "Enter 执行选中项,或直接按 y/a/d", + _ => "Enter selected option, or press y/a/d directly", } } struct ApprovalOptionRow { - option: crate::tui::approval::ApprovalOption, label: &'static str, key_hint: &'static str, dangerous: bool, } fn approval_options_for(risk: RiskLevel, locale: Locale) -> [ApprovalOptionRow; 4] { - use crate::tui::approval::ApprovalOption as O; let dangerous = matches!(risk, RiskLevel::Destructive); [ ApprovalOptionRow { - option: O::ApproveOnce, label: option_approve_once(locale), key_hint: "1 / y", dangerous, }, ApprovalOptionRow { - option: O::ApproveAlways, label: option_approve_always(locale), key_hint: "2 / a", dangerous, }, ApprovalOptionRow { - option: O::Deny, label: option_deny(locale), key_hint: "3 / d / n", dangerous: false, }, ApprovalOptionRow { - option: O::Abort, label: option_abort(locale), key_hint: "Esc", dangerous: false, @@ -3479,6 +3368,43 @@ mod tests { } } + #[test] + fn approval_selected_destructive_option_uses_contrasting_highlight() { + let request = crate::tui::approval::ApprovalRequest::new( + "approval-1", + "exec_shell", + "Run git commit", + &serde_json::json!({ "command": "git commit -m fix" }), + "exec_shell:git commit", + ); + let view = crate::tui::approval::ApprovalView::new(request.clone()); + let widget = ApprovalWidget::new(&request, &view); + let area = Rect::new(0, 0, 100, 30); + let mut buf = Buffer::empty(area); + + widget.render(area, &mut buf); + + let selected_row = (area.y..area.y.saturating_add(area.height)) + .find(|&y| { + (area.x..area.x.saturating_add(area.width)) + .any(|x| buf[(x, y)].bg == palette::DEEPSEEK_BLUE) + }) + .expect("selected approval row should use blue background"); + let highlighted_cells = (area.x..area.x.saturating_add(area.width)) + .filter(|&x| { + let cell = &buf[(x, selected_row)]; + !cell.symbol().trim().is_empty() + && cell.bg == palette::DEEPSEEK_BLUE + && cell.fg == palette::SELECTION_TEXT + }) + .count(); + + assert!( + highlighted_cells >= 4, + "selected destructive option should render visible blue/white text" + ); + } + /// Regression for issue #65: after `App::handle_resize`, the chat widget /// must produce a clean render at the new width — no stale wrapping, /// no panic, no content exceeding the requested width. Cycling through From c238aec6383ed21592223860527f53c4309abcbe Mon Sep 17 00:00:00 2001 From: Reid <61492567+reidliu41@users.noreply.github.com> Date: Tue, 26 May 2026 07:18:03 +0800 Subject: [PATCH 005/283] fix(tui): keep model picker selection on Esc Harvested from PR #2056 by @reidliu41. Co-authored-by: reidliu41 --- crates/tui/src/tui/model_picker.rs | 49 ++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/tui/model_picker.rs b/crates/tui/src/tui/model_picker.rs index 88ce4949..d648fe50 100644 --- a/crates/tui/src/tui/model_picker.rs +++ b/crates/tui/src/tui/model_picker.rs @@ -3,7 +3,8 @@ //! //! Two side-by-side panes — Models on the left, Thinking effort on the //! right. Tab swaps focus, ↑/↓ moves within the focused pane, Enter applies -//! both and closes the modal, Esc cancels. +//! both and closes the modal. Esc closes immediately when nothing moved; after +//! a selection move it keeps the highlighted choice. //! //! The effort pane intentionally only exposes `Off / High / Max`. Per //! DeepSeek's [Thinking Mode docs](https://api-docs.deepseek.com/guides/reasoning_model), @@ -61,6 +62,7 @@ pub struct ModelPickerView { selected_model_idx: usize, selected_effort_idx: usize, focus: Pane, + selection_touched: bool, /// True when the active model is one we don't list — we still show it /// so the picker doesn't quietly forget the user's chosen IDs. show_custom_model_row: bool, @@ -108,6 +110,7 @@ impl ModelPickerView { selected_model_idx, selected_effort_idx, focus: Pane::Model, + selection_touched: false, show_custom_model_row, hide_deepseek_models, } @@ -146,36 +149,42 @@ impl ModelPickerView { PICKER_EFFORTS[self.selected_effort_idx] } - fn move_up(&mut self) { + fn move_up(&mut self) -> bool { match self.focus { Pane::Model => { if self.selected_model_idx > 0 { self.selected_model_idx -= 1; + return true; } } Pane::Effort => { if self.selected_effort_idx > 0 { self.selected_effort_idx -= 1; + return true; } } } + false } - fn move_down(&mut self) { + fn move_down(&mut self) -> bool { match self.focus { Pane::Model => { let max = self.model_row_count().saturating_sub(1); if self.selected_model_idx < max { self.selected_model_idx += 1; + return true; } } Pane::Effort => { let max = PICKER_EFFORTS.len().saturating_sub(1); if self.selected_effort_idx < max { self.selected_effort_idx += 1; + return true; } } } + false } fn toggle_focus(&mut self) { @@ -265,14 +274,15 @@ impl ModalView for ModelPickerView { fn handle_key(&mut self, key: KeyEvent) -> ViewAction { match key.code { + KeyCode::Esc if self.selection_touched => ViewAction::EmitAndClose(self.build_event()), KeyCode::Esc => ViewAction::Close, KeyCode::Enter => ViewAction::EmitAndClose(self.build_event()), KeyCode::Up => { - self.move_up(); + self.selection_touched |= self.move_up(); ViewAction::None } KeyCode::Down => { - self.move_down(); + self.selection_touched |= self.move_down(); ViewAction::None } KeyCode::Tab | KeyCode::Right | KeyCode::Left | KeyCode::BackTab => { @@ -567,7 +577,7 @@ mod tests { } #[test] - fn esc_closes_without_emitting() { + fn immediate_esc_closes_without_emitting() { let (app, _lock) = create_test_app(); let mut view = ModelPickerView::new(&app); let action = view.handle_key(KeyEvent::new( @@ -577,6 +587,33 @@ mod tests { assert!(matches!(action, ViewAction::Close)); } + #[test] + fn esc_after_selection_move_applies_highlighted_model() { + let (app, _lock) = create_test_app(); + let mut view = ModelPickerView::new(&app); + view.handle_key(KeyEvent::new( + KeyCode::Down, + crossterm::event::KeyModifiers::NONE, + )); + + let action = view.handle_key(KeyEvent::new( + KeyCode::Esc, + crossterm::event::KeyModifiers::NONE, + )); + + match action { + ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied { + model, + previous_model, + .. + }) => { + assert_eq!(previous_model, "deepseek-v4-pro"); + assert_eq!(model, "deepseek-v4-flash"); + } + other => panic!("expected Esc to apply highlighted model, got {other:?}"), + } + } + #[test] fn picker_only_exposes_auto_off_high_max() { let labels: Vec<&str> = PICKER_EFFORTS From aa468c30786fee80f3e3447edbbd750e7a890ec8 Mon Sep 17 00:00:00 2001 From: hexin <372726039@qq.com> Date: Tue, 26 May 2026 07:25:07 +0800 Subject: [PATCH 006/283] fix(engine): use user role for sub-agent completion runtime message Harvested from PR #2057 by @h3c-hexin. Co-authored-by: hexin Co-authored-by: Claude Opus 4.7 (1M context) --- crates/tui/src/core/engine/turn_loop.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 9f2da5ff..cf04a955 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -2021,8 +2021,16 @@ impl Engine { } fn subagent_completion_runtime_message(payload: &str) -> Message { + // Role is "user", not "system": some OpenAI-compatible backends apply a + // strict chat template (e.g. vLLM serving Qwen3) that requires any system + // message to be messages[0]. A system message appended mid-conversation + // makes the template raise "System message must be at the beginning", + // which surfaces as a 400 BadRequest and breaks the whole sub-agent + // hand-off in the parent turn. The `visibility="internal"` tag already + // tells the model this is a runtime event rather than user input, so the + // role carries no semantic weight here — only template-compatibility cost. Message { - role: "system".to_string(), + role: "user".to_string(), content: vec![ContentBlock::Text { text: format!( "\n\ @@ -2122,12 +2130,16 @@ mod tests { use super::*; #[test] - fn subagent_completion_handoff_is_internal_system_message() { + fn subagent_completion_handoff_is_internal_user_message() { let message = subagent_completion_runtime_message( "Build passed\n{\"agent_id\":\"agent_a\"}", ); - assert_eq!(message.role, "system"); + // Must be "user", not "system": a system message appended mid-stream + // trips strict chat templates (vLLM/Qwen3) into a 400 BadRequest + // ("System message must be at the beginning"). The internal-event + // framing lives in the text + visibility tag, not the role. + assert_eq!(message.role, "user"); let text = match &message.content[0] { ContentBlock::Text { text, .. } => text, other => panic!("expected text block, got {other:?}"), From 9c8e482607359c34ec608de4b7a1406cadec1a67 Mon Sep 17 00:00:00 2001 From: hexin <372726039@qq.com> Date: Tue, 26 May 2026 07:25:58 +0800 Subject: [PATCH 007/283] fix(engine): keep auto-compaction working on sub-500K self-hosted windows Harvested from PR #2060 by @h3c-hexin. Co-authored-by: hexin Co-authored-by: Claude Opus 4.7 (1M context) --- crates/tui/src/core/engine.rs | 17 +++------ crates/tui/src/core/engine/capacity_flow.rs | 2 +- crates/tui/src/core/engine/context.rs | 32 +++++++++++++++-- crates/tui/src/core/engine/tests.rs | 40 +++++++++------------ crates/tui/src/core/engine/turn_loop.rs | 16 ++------- crates/tui/src/models.rs | 22 ++++++++---- 6 files changed, 70 insertions(+), 59 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 7aad7ae6..a683ebe3 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1287,15 +1287,8 @@ impl Engine { removed } - async fn recover_context_overflow( - &mut self, - client: &DeepSeekClient, - reason: &str, - requested_output_tokens: u32, - ) -> bool { - let Some(target_budget) = - context_input_budget(&self.session.model, requested_output_tokens) - else { + async fn recover_context_overflow(&mut self, client: &DeepSeekClient, reason: &str) -> bool { + let Some(target_budget) = context_input_budget(&self.session.model) else { return false; }; @@ -1971,9 +1964,9 @@ mod handle; pub(crate) use context::compact_tool_result_for_context; use context::{ COMPACTION_SUMMARY_MARKER, MAX_CONTEXT_RECOVERY_ATTEMPTS, MIN_RECENT_MESSAGES_TO_KEEP, - TURN_MAX_OUTPUT_TOKENS, context_input_budget, effective_max_output_tokens, - estimate_input_tokens_conservative, extract_compaction_summary_prompt, - is_context_length_error_message, summarize_text, turn_response_headroom_tokens, + context_input_budget, effective_max_output_tokens, estimate_input_tokens_conservative, + extract_compaction_summary_prompt, is_context_length_error_message, summarize_text, + turn_response_headroom_tokens, }; mod dispatch; mod loop_guard; diff --git a/crates/tui/src/core/engine/capacity_flow.rs b/crates/tui/src/core/engine/capacity_flow.rs index cee5fb76..fe357762 100644 --- a/crates/tui/src/core/engine/capacity_flow.rs +++ b/crates/tui/src/core/engine/capacity_flow.rs @@ -435,7 +435,7 @@ impl Engine { } if !refreshed { - let target_budget = context_input_budget(&self.session.model, TURN_MAX_OUTPUT_TOKENS) + let target_budget = context_input_budget(&self.session.model) .unwrap_or(self.config.compaction.token_threshold.max(1)); if self.estimated_input_tokens() > target_budget { let trimmed = self.trim_oldest_messages_to_budget(target_budget); diff --git a/crates/tui/src/core/engine/context.rs b/crates/tui/src/core/engine/context.rs index cb97e774..6a28d6b4 100644 --- a/crates/tui/src/core/engine/context.rs +++ b/crates/tui/src/core/engine/context.rs @@ -354,9 +354,35 @@ pub(super) fn estimate_input_tokens_conservative( .saturating_add(framing_overhead) } -pub(super) fn context_input_budget(model: &str, requested_output_tokens: u32) -> Option { - let window = usize::try_from(context_window_for_model(model)?).ok()?; - let output = usize::try_from(requested_output_tokens).ok()?; +/// Context windows at or above this size reserve the full +/// [`TURN_MAX_OUTPUT_TOKENS`] (262K) when computing the internal input budget, +/// leaving room for V4-class interleaved thinking. Below it, the reservation +/// falls back to [`effective_max_output_tokens`] so a smaller self-hosted +/// window does not underflow to a negative budget. +const INTERNAL_BUDGET_LARGE_WINDOW_THRESHOLD: u32 = 500_000; + +/// Internal input-side token budget for a model: `window - reserved_output - +/// headroom`. Used by the preflight check, emergency recovery, and capacity +/// trimming to decide when to compact. +/// +/// The reserved-output term is window-dependent: +/// * `window >= 500K` (V4-class large-context) -> [`TURN_MAX_OUTPUT_TOKENS`] +/// (262K). Preserves the "leave room for interleaved thinking" contract. +/// * `window < 500K` (smaller / self-hosted, e.g. a 256K vLLM Qwen window) +/// -> [`effective_max_output_tokens`], i.e. what the API actually caps +/// output at. Reserving the full 262K here would compute +/// `256K - 262K - 1K`, which underflows `checked_sub` to `None` and +/// *silently disables every preflight and emergency recovery path* — the +/// session then runs until the provider hard-rejects on context length. +pub(super) fn context_input_budget(model: &str) -> Option { + let window_tokens = context_window_for_model(model)?; + let window = usize::try_from(window_tokens).ok()?; + let reserved_output = if window_tokens >= INTERNAL_BUDGET_LARGE_WINDOW_THRESHOLD { + TURN_MAX_OUTPUT_TOKENS + } else { + effective_max_output_tokens(model) + }; + let output = usize::try_from(reserved_output).ok()?; window .checked_sub(output) .and_then(|v| v.checked_sub(CONTEXT_HEADROOM_TOKENS)) diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 851b09ea..e68f5fb2 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -1,5 +1,6 @@ use super::*; +use super::context::TURN_MAX_OUTPUT_TOKENS; use crate::models::SystemBlock; use crate::test_support::lock_test_env; use crate::tools::spec::ToolCapability; @@ -916,7 +917,7 @@ fn detects_context_length_errors_from_provider_payloads() { fn context_budget_reserves_output_and_headroom() { // V4 has a 1M context window — the only family that comfortably hosts // a 256K output reservation without saturating the input budget to 0. - let budget = context_input_budget("deepseek-v4-pro", TURN_MAX_OUTPUT_TOKENS) + let budget = context_input_budget("deepseek-v4-pro") .expect("deepseek-v4-pro should have a known context window"); let v4_window: usize = 1_000_000; let expected = v4_window - (TURN_MAX_OUTPUT_TOKENS as usize) - 1_024usize; @@ -943,31 +944,24 @@ fn effective_max_output_tokens_caps_api_request_for_large_window_models() { } #[test] -fn internal_context_budget_unaffected_by_api_request_cap() { - // The internal context budget (used for compaction/preflight/recovery) - // must still use the full TURN_MAX_OUTPUT_TOKENS headroom, NOT the - // smaller API request cap. This ensures long-context V4 sessions don't - // compact prematurely. - let internal_budget = context_input_budget("deepseek-v4-pro", TURN_MAX_OUTPUT_TOKENS) - .expect("V4 should have a known context window"); - let api_cap_budget = context_input_budget( - "deepseek-v4-pro", - effective_max_output_tokens("deepseek-v4-pro"), - ) - .expect("V4 should have a known context window"); - - // Internal budget reserves 262K for output; API-cap budget would only - // reserve 64K. Internal budget must be smaller (more conservative). - assert!( - internal_budget < api_cap_budget, - "Internal budget ({internal_budget}) should be smaller than API-cap budget ({api_cap_budget}) \ - because it reserves more headroom for output" - ); - - // Verify the internal budget is what the compaction logic actually uses. +fn internal_context_budget_tiers_reserved_output_by_window() { + // Large-context (>=500K) models reserve the full TURN_MAX_OUTPUT_TOKENS + // headroom so long V4 sessions don't compact prematurely. + let internal_budget = + context_input_budget("deepseek-v4-pro").expect("V4 should have a known context window"); let v4_window: usize = 1_000_000; let expected_internal = v4_window - (TURN_MAX_OUTPUT_TOKENS as usize) - 1_024usize; assert_eq!(internal_budget, expected_internal); + + // Sub-500K windows cross into the effective-cap branch: a 256K self-hosted + // deployment must yield a usable positive budget rather than None. The + // previous formula reserved the full 262K and computed 256K - 262K - 1K, + // which underflowed to None and silently disabled preflight/recovery. + let small_window_budget = context_input_budget("qwen3-32b-256k") + .expect("a 256K-suffix model must yield Some budget via the effective-cap branch"); + let effective_output = effective_max_output_tokens("qwen3-32b-256k") as usize; + let expected_small = 256_000 - effective_output - 1_024; + assert_eq!(small_window_budget, expected_small); } #[test] diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index cf04a955..70f94f25 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -173,9 +173,7 @@ impl Engine { continue; } - if let Some(input_budget) = - context_input_budget(&self.session.model, TURN_MAX_OUTPUT_TOKENS) - { + if let Some(input_budget) = context_input_budget(&self.session.model) { let estimated_input = self.estimated_input_tokens(); if estimated_input > input_budget { if context_recovery_attempts >= MAX_CONTEXT_RECOVERY_ATTEMPTS { @@ -192,11 +190,7 @@ impl Engine { } if self - .recover_context_overflow( - &client, - "preflight token budget", - TURN_MAX_OUTPUT_TOKENS, - ) + .recover_context_overflow(&client, "preflight token budget") .await { context_recovery_attempts = context_recovery_attempts.saturating_add(1); @@ -326,11 +320,7 @@ impl Engine { if is_context_length_error_message(&message) && context_recovery_attempts < MAX_CONTEXT_RECOVERY_ATTEMPTS && self - .recover_context_overflow( - &client, - "provider context-length rejection", - TURN_MAX_OUTPUT_TOKENS, - ) + .recover_context_overflow(&client, "provider context-length rejection") .await { context_recovery_attempts = context_recovery_attempts.saturating_add(1); diff --git a/crates/tui/src/models.rs b/crates/tui/src/models.rs index a5f52c6d..91c642e1 100644 --- a/crates/tui/src/models.rs +++ b/crates/tui/src/models.rs @@ -208,16 +208,22 @@ pub struct Usage { } /// Map known models to their approximate context window sizes. +/// +/// Lookup order: +/// 1. An explicit `_Nk` suffix in the model name, for **any** vendor. This +/// lets self-hosted deployments advertise their window through the served +/// model name (e.g. a vLLM `--served-model-name qwen3-32b-256k`), which is +/// the only signal we have for non-DeepSeek/Claude models. The 1000-token +/// approximation is fine for compaction-threshold math. +/// 2. DeepSeek vendor heuristics (V4 family -> 1M, legacy -> 128K). +/// 3. Claude -> 200K. #[must_use] pub fn context_window_for_model(model: &str) -> Option { let lower = model.to_lowercase(); - // Unknown legacy DeepSeek model IDs default to 128K unless an explicit - // *k suffix is present. DeepSeek-V4 family and current compatibility - // aliases ship with a 1M context window. + if let Some(explicit_window) = explicit_context_window_hint(&lower) { + return Some(explicit_window); + } if lower.contains("deepseek") { - if let Some(explicit_window) = deepseek_context_window_hint(&lower) { - return Some(explicit_window); - } if lower.contains("v4") { return Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS); } @@ -229,7 +235,9 @@ pub fn context_window_for_model(model: &str) -> Option { None } -fn deepseek_context_window_hint(model_lower: &str) -> Option { +/// Parse an explicit `_Nk` context-window hint from a model name (vendor +/// agnostic). Returns the window in tokens for `N` in `8..=1024`. +fn explicit_context_window_hint(model_lower: &str) -> Option { let bytes = model_lower.as_bytes(); let mut i = 0usize; while i < bytes.len() { From 7976374e91ee20475180606fa4fb73a2a9227dee Mon Sep 17 00:00:00 2001 From: cyq <61975706+cyq1017@users.noreply.github.com> Date: Tue, 26 May 2026 07:26:39 +0800 Subject: [PATCH 008/283] fix(tui): emit subagent completion before terminal update Harvested from PR #2120 by @cyq1017. Co-authored-by: cyq <15000851237@163.com> --- crates/tui/src/tools/subagent/mod.rs | 24 +++++----- crates/tui/src/tools/subagent/tests.rs | 63 ++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 166de979..9da72d85 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -3510,12 +3510,6 @@ async fn run_subagent_task(task: SubAgentTask) { ) .await; - let mut manager = task.manager_handle.write().await; - match &result { - Ok(res) => manager.update_from_result(&task.agent_id, res.clone()), - Err(err) => manager.update_failed(&task.agent_id, err.to_string()), - } - // Emit BOTH a human-friendly summary (rendered in the parent's // sidebar / cell) AND a structured sentinel the model can recognize // on its next turn. Format: human summary on the first line, @@ -3548,16 +3542,24 @@ async fn run_subagent_task(task: SubAgentTask) { } let payload = format!("{summary}\n{sentinel}"); + let agent_id = task.agent_id.clone(); // Wake the engine's parent turn loop if this is one of its direct - // children (issue #756). Gating by `spawn_depth == 1` means the parent - // only sees completions for agents it directly orchestrated, not for - // grandchildren spawned recursively inside its children. - emit_parent_completion(&task.runtime, &task.agent_id, &payload); + // children (issue #756). Issue #1961 also requires emit to happen + // before marking the manager terminal state so the parent can observe the + // completion while its "running children" gate is still open. If we + // update first, the parent can finalize before the completion arrives. + emit_parent_completion(&task.runtime, &agent_id, &payload); + + let mut manager = task.manager_handle.write().await; + match &result { + Ok(res) => manager.update_from_result(&agent_id, res.clone()), + Err(err) => manager.update_failed(&agent_id, err.to_string()), + } if let Some(event_tx) = task.runtime.event_tx { let _ = event_tx.try_send(Event::AgentComplete { - id: task.agent_id, + id: agent_id.clone(), result: payload, }); } diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 7f746412..39fd5780 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -2030,6 +2030,69 @@ fn emit_parent_completion_dropped_receiver_does_not_panic() { ); } +#[tokio::test] +async fn run_subagent_task_emits_parent_completion_before_terminal_update() { + let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 2))); + let (task_input_tx, task_input_rx) = mpsc::unbounded_channel(); + let agent_id = "agent_noop".to_string(); + let mut agent = SubAgent::new( + agent_id.clone(), + SubAgentType::General, + "noop".to_string(), + make_assignment(), + "deepseek-v4-flash".to_string(), + None, + None, + task_input_tx, + "boot_test".to_string(), + ); + agent.status = SubAgentStatus::Running; + manager.write().await.agents.insert(agent_id.clone(), agent); + + let (completion_tx, mut completion_rx) = mpsc::unbounded_channel::(); + let mut runtime = runtime_with_depth(1, Some(completion_tx)); + runtime.manager = Arc::clone(&manager); + + let task = SubAgentTask { + manager_handle: manager.clone(), + runtime, + agent_id: agent_id.clone(), + agent_type: SubAgentType::General, + prompt: "no-op child run".to_string(), + assignment: make_assignment(), + allowed_tools: None, + fork_context: false, + started_at: Instant::now(), + max_steps: 0, + input_rx: task_input_rx, + }; + + let manager_lock = manager.write().await; + let task_handle = tokio::spawn(run_subagent_task(task)); + + // While the manager write lock is held, completion can be emitted only if it + // is sent before the terminal-state manager update (the ordering fixed by + // issue #1961). + let completion = tokio::time::timeout(Duration::from_secs(1), completion_rx.recv()) + .await + .expect("completion should be emitted while manager write lock is still held"); + let completion = completion.expect("completion channel should remain open"); + assert_eq!(completion.agent_id, agent_id); + + drop(manager_lock); + task_handle + .await + .expect("run_subagent_task should complete after lock release"); + + let snapshot = { + let manager = manager.read().await; + manager + .get_result(&agent_id) + .expect("completed agent should be present") + }; + assert_eq!(snapshot.status, SubAgentStatus::Completed); +} + #[test] fn child_runtime_propagates_completion_tx_for_gating() { // The channel is cloned through `child_runtime()` so descendants carry From 43f317d5242a53b54166eccc2967305464984edb Mon Sep 17 00:00:00 2001 From: cyq <61975706+cyq1017@users.noreply.github.com> Date: Tue, 26 May 2026 07:27:24 +0800 Subject: [PATCH 009/283] fix(tui): start actionable goal prompts Harvested from PR #2097 by @cyq1017. Co-authored-by: cyq <15000851237@163.com> --- crates/tui/src/commands/goal.rs | 82 +++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/commands/goal.rs b/crates/tui/src/commands/goal.rs index 47a4d62e..9bc2a945 100644 --- a/crates/tui/src/commands/goal.rs +++ b/crates/tui/src/commands/goal.rs @@ -1,6 +1,6 @@ //! /goal command — set a session objective with token budget and progress tracking. -use crate::tui::app::App; +use crate::tui::app::{App, AppAction}; use super::CommandResult; @@ -26,6 +26,10 @@ pub fn goal(app: &mut App, arg: Option<&str>) -> CommandResult { Some(text) if !text.is_empty() => { // Parse optional budget: "/goal Implement login | budget: 50000" let (objective, budget) = parse_goal_budget(text); + let objective = objective.trim().to_string(); + if objective.is_empty() || objective.chars().all(|c| c == '|') { + return CommandResult::error("Usage: /goal [budget: N]"); + } app.goal.goal_objective = Some(objective.clone()); app.goal.goal_token_budget = budget; app.goal.goal_started_at = Some(std::time::Instant::now()); @@ -33,9 +37,10 @@ pub fn goal(app: &mut App, arg: Option<&str>) -> CommandResult { let budget_str = budget .map(|b| format!(" (budget: {b} tokens)")) .unwrap_or_default(); - CommandResult::message(format!( - "Goal set: \"{objective}\"{budget_str} — tracking progress." - )) + CommandResult::with_message_and_action( + format!("Goal set: \"{objective}\"{budget_str} — tracking progress."), + AppAction::SendMessage(objective), + ) } _ => { // Show current goal @@ -102,6 +107,7 @@ fn parse_goal_budget(text: &str) -> (String, Option) { mod tests { use super::*; use crate::config::Config; + use crate::tui::app::AppAction; use crate::tui::app::{App, TuiOptions}; use std::path::PathBuf; @@ -139,6 +145,34 @@ mod tests { app.goal.goal_objective.as_deref(), Some("Fix the login bug") ); + assert!(matches!( + result.action, + Some(AppAction::SendMessage(msg)) if msg == "Fix the login bug" + )); + } + + #[test] + fn test_execute_goal_dispatched_as_sendmessage() { + let mut app = create_test_app(); + let result = crate::commands::execute("/goal Implement login flow", &mut app); + assert!( + result + .message + .is_some_and(|message| message.contains("Goal set")) + ); + assert!(matches!( + result.action, + Some(AppAction::SendMessage(content)) + if content == "Implement login flow".to_string() + )); + } + + #[test] + fn test_execute_goal_without_argument_shows_state() { + let mut app = create_test_app(); + let result = crate::commands::execute("/goal", &mut app); + assert!(result.action.is_none()); + assert!(matches!(result.message.as_deref(), Some(value) if value.contains("No goal set"))); } #[test] @@ -147,6 +181,46 @@ mod tests { let _ = goal(&mut app, Some("Refactor auth | budget: 50000")); assert_eq!(app.goal.goal_objective.as_deref(), Some("Refactor auth")); assert_eq!(app.goal.goal_token_budget, Some(50_000)); + assert!(app.goal.goal_started_at.is_some()); + } + + #[test] + fn test_set_goal_rejects_budget_only_objective() { + let mut app = create_test_app(); + app.goal.goal_objective = Some("existing objective".to_string()); + app.goal.goal_token_budget = Some(10_000); + + let result = crate::commands::execute("/goal budget: 50000", &mut app); + assert!(result.is_error); + assert!(result.action.is_none()); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("Usage: /goal") + ); + assert_eq!( + app.goal.goal_objective.as_deref(), + Some("existing objective") + ); + assert_eq!(app.goal.goal_token_budget, Some(10_000)); + + let pipe_result = crate::commands::execute("/goal | budget: 50000", &mut app); + assert!(pipe_result.is_error); + assert!(pipe_result.action.is_none()); + assert!( + pipe_result + .message + .as_deref() + .unwrap_or_default() + .contains("Usage: /goal") + ); + assert_eq!( + app.goal.goal_objective.as_deref(), + Some("existing objective") + ); + assert_eq!(app.goal.goal_token_budget, Some(10_000)); } #[test] From b45287133bbfae50d168e0093377c97ec4bf4470 Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Tue, 26 May 2026 07:28:04 +0800 Subject: [PATCH 010/283] feat: embed user prompt in snapshot labels for readable /restore listings Harvested from PR #2111 by @idling11. Co-authored-by: Hanmiao Li <894876246@qq.com> --- crates/tui/src/core/engine.rs | 21 +++++++++++-- crates/tui/src/core/turn.rs | 55 ++++++++++++++++++++++++++++++++--- 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index a683ebe3..f98f523c 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -934,11 +934,17 @@ impl Engine { // work on the blocking pool so the async runtime stays responsive; // failure is non-fatal (the helper logs at WARN). if self.config.snapshots_enabled { + // Clone the user prompt now — `content` is moved into + // `user_text_message_with_turn_metadata` below, so we need + // a copy for both pre- and post-turn snapshot labels. The + // label carries a truncated first line so `/restore` + // listings are human-readable. + let snapshot_prompt = content.clone(); let pre_workspace = self.session.workspace.clone(); let pre_seq = self.turn_counter; let pre_cap = self.config.snapshots_max_workspace_bytes; let _ = tokio::task::spawn_blocking(move || { - pre_turn_snapshot(&pre_workspace, pre_seq, pre_cap) + pre_turn_snapshot(&pre_workspace, pre_seq, pre_cap, Some(&snapshot_prompt)) }) .await; } @@ -949,6 +955,10 @@ impl Engine { // turns (#499). crate::retry_status::clear(); + // Clone user prompt for post-turn snapshot label before `content` + // is moved into `user_text_message_with_turn_metadata` below. + let snapshot_prompt_post = content.clone(); + // Check if we have the appropriate client if self.deepseek_client.is_none() { let message = self @@ -1158,11 +1168,18 @@ impl Engine { // paste immediately (#234). The git work proceeds on the blocking // pool without forcing the engine loop to await it. if self.config.snapshots_enabled { + // `snapshot_prompt_post` was cloned from `content` above, + // before `content` was moved into the session messages. let post_workspace = self.session.workspace.clone(); let post_seq = self.turn_counter; let post_cap = self.config.snapshots_max_workspace_bytes; crate::utils::spawn_blocking_supervised("post-turn-snapshot", move || { - post_turn_snapshot(&post_workspace, post_seq, post_cap); + post_turn_snapshot( + &post_workspace, + post_seq, + post_cap, + Some(&snapshot_prompt_post), + ); }); } } diff --git a/crates/tui/src/core/turn.rs b/crates/tui/src/core/turn.rs index 049bc44a..b4a551dc 100644 --- a/crates/tui/src/core/turn.rs +++ b/crates/tui/src/core/turn.rs @@ -128,16 +128,54 @@ fn add_optional_usage(total: Option, delta: Option) -> Option { } } +/// Maximum characters of the user prompt snippet to embed in a snapshot +/// label. Longer prompts are truncated with an ellipsis. +const USER_PROMPT_LABEL_MAX: usize = 100; + +/// Format a snapshot label that includes the user prompt for readability +/// in `/restore` listings. +/// +/// Takes the first line of the prompt (up to `USER_PROMPT_LABEL_MAX` +/// characters) and appends it to the traditional `type:seq` label so +/// users can identify which turn each snapshot belongs to. +fn format_snapshot_label(prefix: &str, turn_seq: u64, user_prompt: Option<&str>) -> String { + let base = format!("{prefix}:{turn_seq}"); + match user_prompt { + None | Some("") => base, + Some(prompt) => { + let first_line = prompt.lines().next().unwrap_or(""); + let truncated: String = first_line.chars().take(USER_PROMPT_LABEL_MAX).collect(); + if truncated.chars().count() < first_line.chars().count() { + format!("{base}: {truncated}…") + } else { + format!("{base}: {truncated}") + } + } + } +} + /// Take a `pre-turn:` workspace snapshot. /// /// `cap_bytes` is the workspace-size ceiling that gates first-init /// (passed through to [`SnapshotRepo::open_or_init_with_cap`]); pass /// `0` to disable the cap. +/// `user_prompt` is an optional snippet of the user's message for this +/// turn, embedded in the snapshot label so `/restore` listings are +/// human-readable. /// /// Returns the snapshot SHA on success, `None` on any error. Errors are /// logged at WARN; the turn loop must not block on this. -pub fn pre_turn_snapshot(workspace: &Path, turn_seq: u64, cap_bytes: u64) -> Option { - snapshot_with_label(workspace, &format!("pre-turn:{turn_seq}"), cap_bytes) +pub fn pre_turn_snapshot( + workspace: &Path, + turn_seq: u64, + cap_bytes: u64, + user_prompt: Option<&str>, +) -> Option { + snapshot_with_label( + workspace, + &format_snapshot_label("pre-turn", turn_seq, user_prompt), + cap_bytes, + ) } /// Take a `tool:` workspace snapshot, taken before executing a @@ -154,8 +192,17 @@ pub fn pre_tool_snapshot(workspace: &Path, call_id: &str, cap_bytes: u64) -> Opt /// Take a `post-turn:` workspace snapshot. Same failure model as /// [`pre_turn_snapshot`]. -pub fn post_turn_snapshot(workspace: &Path, turn_seq: u64, cap_bytes: u64) -> Option { - snapshot_with_label(workspace, &format!("post-turn:{turn_seq}"), cap_bytes) +pub fn post_turn_snapshot( + workspace: &Path, + turn_seq: u64, + cap_bytes: u64, + user_prompt: Option<&str>, +) -> Option { + snapshot_with_label( + workspace, + &format_snapshot_label("post-turn", turn_seq, user_prompt), + cap_bytes, + ) } fn snapshot_with_label(workspace: &Path, label: &str, cap_bytes: u64) -> Option { From 1a78e3cc7ec8b09d0b19a1d7f1213476a6cfd1d5 Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Tue, 26 May 2026 07:28:48 +0800 Subject: [PATCH 011/283] feat(tui): add sidebar hover tooltip for truncated Work/Tasks lines Harvested from PR #2110 by @idling11. Co-authored-by: Hanmiao Li <894876246@qq.com> --- crates/tui/src/tui/app.rs | 25 ++++++++ crates/tui/src/tui/mouse_ui.rs | 37 ++++++++++++ crates/tui/src/tui/sidebar.rs | 101 ++++++++++++++++++++++++++++----- crates/tui/src/tui/ui.rs | 28 +++++++++ 4 files changed, 176 insertions(+), 15 deletions(-) diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 4e5e78c0..f0368bf5 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1009,6 +1009,22 @@ pub struct SessionState { pub last_cache_inspection: Option, } +/// Sidebar hover state for mouse tooltip support. +#[derive(Debug, Clone, Default)] +pub struct SidebarHoverState { + /// Rendered sections with their areas and full-text lines. + pub sections: Vec, +} + +/// Per-section metadata for sidebar hover detection. +#[derive(Debug, Clone)] +pub struct SidebarHoverSection { + /// Content area within the section (inside border + padding). + pub content_area: Rect, + /// Full original text for each content line rendered. + pub lines: Vec, +} + impl Default for SessionState { fn default() -> Self { Self { @@ -1156,6 +1172,12 @@ pub struct App { pub transcript_spacing: TranscriptSpacing, pub sidebar_width_percent: u16, pub sidebar_focus: SidebarFocus, + /// Sidebar hover state for mouse tooltip support. + pub sidebar_hover: SidebarHoverState, + /// Current hover tooltip text, if any. + pub sidebar_hover_tooltip: Option, + /// Last known mouse position for tooltip placement. + pub last_mouse_pos: Option<(u16, u16)>, /// Whether the session-context panel is enabled (#504). pub context_panel: bool, /// File-tree pane state. `None` when hidden; `Some` when visible. @@ -1830,6 +1852,9 @@ impl App { transcript_spacing, sidebar_width_percent, sidebar_focus, + sidebar_hover: SidebarHoverState::default(), + sidebar_hover_tooltip: None, + last_mouse_pos: None, context_panel: settings.context_panel, file_tree: None, file_tree_visible: false, diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index 589c31ae..c3c985c1 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -53,6 +53,43 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec { + // Update last mouse position for tooltip rendering. + app.last_mouse_pos = Some((mouse.column, mouse.row)); + + // Check sidebar sections for hover tooltip. + let mut found = false; + for section in &app.sidebar_hover.sections { + if mouse.column >= section.content_area.x + && mouse.column + < section + .content_area + .x + .saturating_add(section.content_area.width) + && mouse.row >= section.content_area.y + && mouse.row + < section + .content_area + .y + .saturating_add(section.content_area.height) + { + let line_idx = (mouse.row.saturating_sub(section.content_area.y)) as usize; + if line_idx < section.lines.len() { + let new_tooltip = section.lines[line_idx].clone(); + if app.sidebar_hover_tooltip.as_deref() != Some(&new_tooltip) { + app.sidebar_hover_tooltip = Some(new_tooltip); + app.needs_redraw = true; + } + found = true; + break; + } + } + } + if !found && app.sidebar_hover_tooltip.is_some() { + app.sidebar_hover_tooltip = None; + app.needs_redraw = true; + } + } MouseEventKind::ScrollUp => { let update = app.viewport.mouse_scroll.on_scroll(ScrollDirection::Up); app.viewport.pending_scroll_delta = app diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index bff5c51a..8b488178 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -22,7 +22,7 @@ use crate::tools::plan::StepStatus; use crate::tools::subagent::SubAgentStatus; use crate::tools::todo::TodoStatus; -use super::app::{App, SidebarFocus, TaskPanelEntry}; +use super::app::{App, SidebarFocus, SidebarHoverSection, SidebarHoverState, TaskPanelEntry}; use super::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus, summarize_tool_output}; use super::subagent_routing::active_fanout_counts; use super::ui_text::{concise_shell_command_label, truncate_line_to_width}; @@ -35,7 +35,9 @@ const RECENT_TOOL_SCAN_LIMIT: usize = 24; const ACTIVE_TOOL_COMPLETED_ROW_TTL: Duration = Duration::from_secs(8); const ACTIVE_TOOL_STALE_RUNNING_ROW_TTL: Duration = Duration::from_secs(600); -pub fn render_sidebar(f: &mut Frame, area: Rect, app: &App) { +pub fn render_sidebar(f: &mut Frame, area: Rect, app: &mut App) { + // Clear hover state at the start of each render + app.sidebar_hover = SidebarHoverState::default(); if area.width < 24 || area.height < 8 { // Paint a styled block over the area so stale cells from a previous // (wider) frame don't persist as bleed-through artifacts (#400). @@ -60,7 +62,7 @@ pub fn render_sidebar(f: &mut Frame, area: Rect, app: &App) { /// Build the Auto-mode panel stack. Empty panels collapse to zero height so /// non-empty ones get the full sidebar real estate. Work appears when it has /// useful content, or as the one quiet empty state when nothing else is active. -fn render_sidebar_auto(f: &mut Frame, area: Rect, app: &App) { +fn render_sidebar_auto(f: &mut Frame, area: Rect, app: &mut App) { let work_has_content = sidebar_work_summary(app).has_useful_content(); let tasks_empty = app.runtime_turn_id.is_none() && app.task_panel.is_empty(); let agents_empty = app.subagent_cache.is_empty() @@ -557,7 +559,7 @@ fn work_panel_empty_hint(content_width: usize) -> String { truncate_line_to_width("No active work", content_width) } -fn render_sidebar_work(f: &mut Frame, area: Rect, app: &App) { +fn render_sidebar_work(f: &mut Frame, area: Rect, app: &mut App) { if area.height < 3 { return; } @@ -572,10 +574,11 @@ fn render_sidebar_work(f: &mut Frame, area: Rect, app: &App) { app.ui_theme.mode, ); - render_sidebar_section(f, area, "Work", lines, app); + let full_texts: Vec = lines.iter().map(|l| spans_to_text(&l.spans)).collect(); + render_sidebar_section(f, area, "Work", lines, full_texts, app); } -fn render_sidebar_tasks(f: &mut Frame, area: Rect, app: &App) { +fn render_sidebar_tasks(f: &mut Frame, area: Rect, app: &mut App) { if area.height < 3 { return; } @@ -584,7 +587,8 @@ fn render_sidebar_tasks(f: &mut Frame, area: Rect, app: &App) { let usable_rows = area.height.saturating_sub(3) as usize; let lines = task_panel_lines(app, content_width.max(1), usable_rows.max(1)); - render_sidebar_section(f, area, "Tasks", lines, app); + let full_texts: Vec = lines.iter().map(|l| spans_to_text(&l.spans)).collect(); + render_sidebar_section(f, area, "Tasks", lines, full_texts, app); } #[derive(Debug, Clone)] @@ -1374,7 +1378,7 @@ fn duration_ms(duration: Duration) -> u64 { u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) } -fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &App) { +fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &mut App) { if area.height < 3 { return; } @@ -1421,7 +1425,7 @@ fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &App) { let rows = sidebar_agent_rows(app); let lines = subagent_panel_lines(&summary, &rows, content_width, usable_rows.max(1)); - render_sidebar_section(f, area, "Agents", lines, app); + render_sidebar_section(f, area, "Agents", lines, Vec::new(), app); } /// Minimal projection of the data the sub-agent sidebar needs. Lifted out @@ -1659,7 +1663,7 @@ fn agent_status_marker(status: &str) -> (&'static str, ratatui::style::Color) { /// cost, MCP server count, LSP toggle state, cycle count, and memory /// file size + mtime. Each section is a compact one-liner so the panel /// reads as a dashboard rather than a scrolling list. -fn render_context_panel(f: &mut Frame, area: Rect, app: &App) { +fn render_context_panel(f: &mut Frame, area: Rect, app: &mut App) { if area.height < 3 { return; } @@ -1789,7 +1793,15 @@ fn render_context_panel(f: &mut Frame, area: Rect, app: &App) { ))); } - render_sidebar_section(f, area, "Session", lines, app); + render_sidebar_section(f, area, "Session", lines, Vec::new(), app); +} + +fn spans_to_text(spans: &[Span<'_>]) -> String { + let mut s = String::new(); + for span in spans { + s.push_str(span.content.as_ref()); + } + s } fn render_sidebar_section( @@ -1797,7 +1809,8 @@ fn render_sidebar_section( area: Rect, title: &str, lines: Vec>, - app: &App, + full_texts: Vec, + app: &mut App, ) { if area.width < 4 || area.height < 3 { // Clear stale cells before bailing out (#400). @@ -1808,6 +1821,19 @@ fn render_sidebar_section( } let theme = Theme::for_palette_mode(app.ui_theme.mode); + + // Record hover metadata for mouse tooltip support. + let padding = theme.section_padding; + let content_area = Rect { + x: area.x + 1 + padding.left, + y: area.y + 1 + padding.top, + width: area.width.saturating_sub(2 + padding.left + padding.right), + height: area.height.saturating_sub(2 + padding.top + padding.bottom), + }; + app.sidebar_hover.sections.push(SidebarHoverSection { + content_area, + lines: full_texts, + }); // Truncate the panel title so it always fits within the section width // even after a resize. The title occupies up to 4 chars of border chrome // (two spaces + one space on each side), so the max title length is @@ -1850,9 +1876,10 @@ fn render_sidebar_section( mod tests { use super::{ ACTIVE_TOOL_COMPLETED_ROW_TTL, ACTIVE_TOOL_STALE_RUNNING_ROW_TTL, AutoSidebarPanel, - AutoSidebarState, SidebarAgentRow, SidebarSubagentSummary, SidebarWorkChecklistItem, - SidebarWorkStrategyStep, SidebarWorkSummary, auto_sidebar_panels, subagent_panel_lines, - task_panel_lines, work_panel_empty_hint, work_panel_lines, + AutoSidebarState, SidebarAgentRow, SidebarHoverSection, SidebarHoverState, + SidebarSubagentSummary, SidebarWorkChecklistItem, SidebarWorkStrategyStep, + SidebarWorkSummary, auto_sidebar_panels, subagent_panel_lines, task_panel_lines, + work_panel_empty_hint, work_panel_lines, }; use crate::config::Config; use crate::palette::PaletteMode; @@ -2664,4 +2691,48 @@ mod tests { "RLM work must be visible in Agents panel: {text:?}" ); } + + // ---- Sidebar hover tooltip tests ---- + + #[test] + fn sidebar_hover_state_default_is_empty() { + let state = SidebarHoverState::default(); + assert!(state.sections.is_empty()); + } + + #[test] + fn sidebar_hover_section_stores_lines() { + use ratatui::layout::Rect; + let section = SidebarHoverSection { + content_area: Rect::new(1, 1, 38, 8), + lines: vec!["line 1".to_string(), "line 2".to_string()], + }; + assert_eq!(section.lines.len(), 2); + assert_eq!(section.lines[0], "line 1"); + assert!(section.content_area.x > 0); + } + + #[test] + fn hover_line_matching_respects_content_area_offset() { + use ratatui::layout::Rect; + let section = SidebarHoverSection { + content_area: Rect::new(62, 2, 36, 6), + lines: vec![ + "first".to_string(), + "second".to_string(), + "third".to_string(), + ], + }; + + // Mouse within content area, first line + let line_idx = (2u16.saturating_sub(section.content_area.y)) as usize; + assert_eq!(section.lines[line_idx], "first"); + + // Mouse within content area, second line + let line_idx = (3u16.saturating_sub(section.content_area.y)) as usize; + assert_eq!(section.lines[line_idx], "second"); + + // Mouse outside content area (above) — row < content_area.y + assert!((1u16) < section.content_area.y); + } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index dffa442c..852e6c4d 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -5817,6 +5817,34 @@ fn render(f: &mut Frame, app: &mut App) { if let Some(sidebar_area) = sidebar_area { super::sidebar::render_sidebar(f, sidebar_area, app); + + // Render sidebar hover tooltip if active. + if let Some(ref tooltip_text) = app.sidebar_hover_tooltip + && let Some((mouse_col, mouse_row)) = app.last_mouse_pos + { + let text_width = (tooltip_text.len() as u16).clamp(10, 60); + let tooltip_height = 1u16; + let x = mouse_col + .saturating_add(2) + .min(size.width.saturating_sub(text_width)); + let y = mouse_row + .saturating_sub(1) + .min(size.height.saturating_sub(tooltip_height)); + if text_width > 0 && tooltip_height > 0 { + let tooltip_area = Rect { + x, + y, + width: text_width, + height: tooltip_height, + }; + let tooltip = ratatui::widgets::Paragraph::new(tooltip_text.as_str()).style( + Style::default() + .bg(palette::STATUS_WARNING) + .fg(palette::TEXT_MUTED), + ); + f.render_widget(tooltip, tooltip_area); + } + } } } From c7bd7f161e50d12d88f0c8054ccf4cde32babcbb Mon Sep 17 00:00:00 2001 From: kitty Date: Tue, 26 May 2026 07:29:22 +0800 Subject: [PATCH 012/283] feat: session title in composer Harvested from PR #2108 by @wdw8276. Co-authored-by: kitty Co-authored-by: Claude Sonnet 4.6 --- crates/tui/src/palette.rs | 3 +-- crates/tui/src/tui/widgets/mod.rs | 40 +++++++++++++++++++++++-------- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/crates/tui/src/palette.rs b/crates/tui/src/palette.rs index c9980200..770308e9 100644 --- a/crates/tui/src/palette.rs +++ b/crates/tui/src/palette.rs @@ -1098,8 +1098,7 @@ fn grayscale_bg_from_luma(luma: u8) -> Color { } fn luma(r: u8, g: u8, b: u8) -> u8 { - let weighted = u32::from(r) * 299 + u32::from(g) * 587 + u32::from(b) * 114; - (weighted / 1000) as u8 + ((u32::from(r) * 299 + u32::from(g) * 587 + u32::from(b) * 114 + 500) / 1000) as u8 } // === Color depth + brightness helpers (v0.6.6 UI redesign) === diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 05ff8a95..a6b2307d 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -1827,7 +1827,8 @@ fn vim_mode_style(mode: VimMode) -> Style { fn composer_top_right_chrome(app: &App, area_width: u16) -> Option> { let receipt = app.active_receipt_text(); - if !app.composer.vim_enabled && receipt.is_none() { + let session_title = app.session_title.as_deref(); + if !app.composer.vim_enabled && receipt.is_none() && session_title.is_none() { return None; } @@ -1866,14 +1867,35 @@ fn composer_top_right_chrome(app: &App, area_width: u16) -> Option ))); } + let mut spans: Vec = Vec::new(); if app.composer.vim_enabled { - return Some(Line::from(Span::styled( + spans.push(Span::styled( truncate_display_width(app.composer.vim_mode.label(), max_width), vim_mode_style(app.composer.vim_mode), - ))); + )); + } + if let Some(title) = session_title { + let used: usize = spans + .iter() + .map(|s| UnicodeWidthStr::width(s.content.as_ref())) + .sum(); + let sep = if spans.is_empty() { 0 } else { 2 }; + let remaining = max_width.saturating_sub(used + sep); + if remaining >= 4 { + if !spans.is_empty() { + spans.push(Span::raw(" ")); + } + spans.push(Span::styled( + truncate_display_width(title, remaining), + Style::default().fg(palette::TEXT_MUTED), + )); + } + } + if spans.is_empty() { + None + } else { + Some(Line::from(spans)) } - - None } fn should_render_empty_state(app: &App) -> bool { @@ -2771,11 +2793,10 @@ mod tests { } #[test] - fn composer_border_does_not_render_session_title() { + fn composer_border_renders_session_title() { let mut app = create_test_app(); app.composer_density = ComposerDensity::Comfortable; - app.session_title = - Some("hello could you please take a look at codewhale-tui and all changes".to_string()); + app.session_title = Some("my-session".to_string()); let slash_menu_entries = Vec::::new(); let mention_menu_entries = Vec::::new(); let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries); @@ -2791,8 +2812,7 @@ mod tests { let rendered = buffer_text(&buf, area); assert!(rendered.contains("Composer")); - assert!(!rendered.contains("codewhale-tui")); - assert!(!rendered.contains("hello could you")); + assert!(rendered.contains("my-session")); } #[test] From 228372935e467c0828f76cf3cfa21e2f36e884f7 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 25 May 2026 18:45:36 -0500 Subject: [PATCH 013/283] chore(release): prepare v0.8.45 Harvested from PR #2118 by @Hmbown. Includes Kimi/Moonshot OAuth, v0.8.45 release prep, the Codex/ChatGPT OAuth removal, open-source-first model defaults, and the safe green PR batch merged into main before the release branch refresh. --- CHANGELOG.md | 69 ++- Cargo.lock | 32 +- Cargo.toml | 2 +- README.md | 21 +- crates/agent/Cargo.toml | 2 +- crates/agent/src/lib.rs | 10 +- crates/app-server/Cargo.toml | 23 +- crates/app-server/src/lib.rs | 336 +++++++++++- crates/app-server/src/main.rs | 15 + crates/cli/Cargo.toml | 14 +- crates/cli/src/lib.rs | 89 ++-- crates/config/Cargo.toml | 3 +- crates/config/src/lib.rs | 255 +++++---- crates/core/Cargo.toml | 16 +- crates/execpolicy/Cargo.toml | 2 +- crates/hooks/Cargo.toml | 2 +- crates/tools/Cargo.toml | 2 +- crates/tui/CHANGELOG.md | 69 ++- crates/tui/Cargo.toml | 6 +- crates/tui/src/config.rs | 2 +- crates/tui/src/main.rs | 91 +++- crates/tui/src/palette.rs | 712 +++++++++++++++++++------- crates/tui/src/pricing.rs | 19 + crates/tui/src/runtime_threads.rs | 8 +- crates/tui/src/session_manager.rs | 8 + crates/tui/src/settings.rs | 106 ---- crates/tui/src/skills/install.rs | 116 ++++- crates/tui/src/theme_qa_audit.rs | 324 ++++++++++++ crates/tui/src/tui/app.rs | 30 +- crates/tui/src/tui/color_compat.rs | 2 +- crates/tui/src/tui/command_palette.rs | 26 - crates/tui/src/tui/footer_ui.rs | 62 +-- crates/tui/src/tui/markdown_render.rs | 4 +- crates/tui/src/tui/mod.rs | 1 - crates/tui/src/tui/session_picker.rs | 1 + crates/tui/src/tui/ui.rs | 108 +--- crates/tui/src/tui/ui/tests.rs | 1 + crates/tui/src/tui/views/mod.rs | 22 - crates/tui/src/tui/voice_input.rs | 127 ----- crates/tui/tests/palette_audit.rs | 50 +- docs/CONFIGURATION.md | 77 +-- npm/codewhale/package.json | 4 +- npm/deepseek-tui/package.json | 2 +- web/app/[locale]/faq/page.tsx | 6 +- 44 files changed, 1879 insertions(+), 998 deletions(-) create mode 100644 crates/tui/src/theme_qa_audit.rs delete mode 100644 crates/tui/src/tui/voice_input.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f6fda0e4..47c20bd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.45] - 2026-05-25 + +### Added + +- **RLM session objects.** `rlm_open` can now load `session://` refs, + exposing the active prompt, history, and session data as symbolic objects + inside RLM REPLs (#2047). +- **Deterministic whale-species sub-agent names.** Sub-agents now get stable, + human-readable whale-species nicknames (e.g. "Beluga", "Orca") while + preserving the raw agent ID in the popup (#2035, #2016). +- **`/balance` command scaffold.** Registered the `/balance` slash command + as a placeholder for future provider billing queries (#2035, #2019). +- **Readable `/restore` snapshot labels.** Snapshot labels now include the + originating user prompt so restore listings are easier to identify. Thanks + @idling11 (#2111). +- **Sidebar hover tooltips.** Truncated Work and Tasks sidebar lines now expose + their full text on hover. Thanks @idling11 (#2110). + +### Changed + +- **AGENTS.md is now maintainer-local.** The project instructions file no + longer ships as a tracked repo file; it lives in maintainer-local ignored + state (#2047). + +### Fixed + +- **Sub-agent completion handoff compatibility.** Completion handoffs now use a + chat-template-safe role and emit before terminal updates, fixing strict + OpenAI-compatible/self-hosted backends and preserving transcript ordering. + Thanks @h3c-hexin and @cyq1017 (#2057, #2120). +- **Self-hosted context budgeting.** Sub-500K self-hosted model windows now keep + a usable input budget instead of disabling preflight compaction after output + reservation underflow. Thanks @h3c-hexin (#2060). +- **Goal prompts start actionable.** Goal-start prompts now open in an + actionable state instead of requiring an extra nudge. Thanks @cyq1017 + (#2097). +- **Composer session title display.** The composer chrome shows the current + session title again and avoids grayscale luma overflow in debug builds. + Thanks @wdw8276 (#2108). +- **Approval prompts use a one-step confirmation flow.** Enter now commits the + selected approval option directly, destructive warnings remain visible, and + abort cancels the active turn instead of only denying the current tool call. + Thanks @reidliu41 (#2143). +- **Model picker selection survives Esc.** Dismissing the model picker with Esc + no longer loses the highlighted selection. Thanks @reidliu41 (#2056). +- **Slash recovery no longer restores command tails in the composer.** + Resuming a session or recovering from a crash no longer leaves stale + slash-command text (e.g. `/sessions`) in the composer input (#2047, #2032). +- **Remembered tool approvals now update the live active turn.** + When the "remember" checkbox is set on an approval dialog, the active + turn's auto-approve flag flips immediately instead of waiting for the + next turn. Thanks @gaord (#2047, #2041). +- **YAML block scalars in SKILL.md frontmatter.** Multi-line descriptions + using `>` or `|` indicators are now parsed correctly — folded block + scalars join non-empty lines with spaces, literal scalars preserve + newlines, and all three chomping modes (strip/clip/keep) are supported. + Thanks @zlh124 (#1908, #1907). +- **User messages highlighted in the transcript.** User-authored messages + now render with a full-row background in the live TUI transcript, making + it easier to scan prior turns. Assistant and system messages are + unaffected. Thanks @reidliu41 (#1995, #1672). +- **Cancellable `list_dir` and `file_search`.** Long directory walks and + file searches now respond to user cancel/stop requests with a 30-second + fallback timeout, preventing the TUI from hanging on deep or slow + filesystems (#2035). + ## [0.8.44] - 2026-05-24 ### Added @@ -4806,7 +4872,8 @@ Welcome — and thank you. - Hooks system and config profiles - Example skills and launch assets -[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.44...HEAD +[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.45...HEAD +[0.8.45]: https://github.com/Hmbown/CodeWhale/compare/v0.8.44...v0.8.45 [0.8.44]: https://github.com/Hmbown/CodeWhale/compare/v0.8.43...v0.8.44 [0.8.43]: https://github.com/Hmbown/CodeWhale/compare/v0.8.42...v0.8.43 [0.8.42]: https://github.com/Hmbown/CodeWhale/compare/v0.8.41...v0.8.42 diff --git a/Cargo.lock b/Cargo.lock index 9da75e3b..e2d1ad83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -803,7 +803,7 @@ checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" [[package]] name = "codewhale-agent" -version = "0.8.44" +version = "0.8.45" dependencies = [ "codewhale-config", "serde", @@ -811,7 +811,7 @@ dependencies = [ [[package]] name = "codewhale-app-server" -version = "0.8.44" +version = "0.8.45" dependencies = [ "anyhow", "axum", @@ -827,13 +827,16 @@ dependencies = [ "codewhale-tools", "serde", "serde_json", + "tempfile", "tokio", + "tower", "tower-http", + "uuid", ] [[package]] name = "codewhale-cli" -version = "0.8.44" +version = "0.8.45" dependencies = [ "anyhow", "chrono", @@ -858,19 +861,20 @@ dependencies = [ [[package]] name = "codewhale-config" -version = "0.8.44" +version = "0.8.45" dependencies = [ "anyhow", "codewhale-secrets", "dirs", "serde", + "serde_json", "toml 0.9.11+spec-1.1.0", "tracing", ] [[package]] name = "codewhale-core" -version = "0.8.44" +version = "0.8.45" dependencies = [ "anyhow", "chrono", @@ -888,7 +892,7 @@ dependencies = [ [[package]] name = "codewhale-execpolicy" -version = "0.8.44" +version = "0.8.45" dependencies = [ "anyhow", "codewhale-protocol", @@ -897,7 +901,7 @@ dependencies = [ [[package]] name = "codewhale-hooks" -version = "0.8.44" +version = "0.8.45" dependencies = [ "anyhow", "async-trait", @@ -911,7 +915,7 @@ dependencies = [ [[package]] name = "codewhale-mcp" -version = "0.8.44" +version = "0.8.45" dependencies = [ "anyhow", "serde", @@ -920,7 +924,7 @@ dependencies = [ [[package]] name = "codewhale-protocol" -version = "0.8.44" +version = "0.8.45" dependencies = [ "serde", "serde_json", @@ -928,7 +932,7 @@ dependencies = [ [[package]] name = "codewhale-secrets" -version = "0.8.44" +version = "0.8.45" dependencies = [ "dirs", "keyring", @@ -941,7 +945,7 @@ dependencies = [ [[package]] name = "codewhale-state" -version = "0.8.44" +version = "0.8.45" dependencies = [ "anyhow", "chrono", @@ -953,7 +957,7 @@ dependencies = [ [[package]] name = "codewhale-tools" -version = "0.8.44" +version = "0.8.45" dependencies = [ "anyhow", "async-trait", @@ -966,7 +970,7 @@ dependencies = [ [[package]] name = "codewhale-tui" -version = "0.8.44" +version = "0.8.45" dependencies = [ "anyhow", "arboard", @@ -1032,7 +1036,7 @@ dependencies = [ [[package]] name = "codewhale-tui-core" -version = "0.8.44" +version = "0.8.45" [[package]] name = "colorchoice" diff --git a/Cargo.toml b/Cargo.toml index cee78462..78d560a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"] resolver = "2" [workspace.package] -version = "0.8.44" +version = "0.8.45" edition = "2024" # Rust 1.88 stabilized `let_chains` in `if`/`while` conditions, which the # codebase relies on extensively. Cargo enforces this so users on older diff --git a/README.md b/README.md index 58975408..411cf0c9 100644 --- a/README.md +++ b/README.md @@ -279,9 +279,13 @@ codewhale --provider novita --model deepseek/deepseek-v4-pro codewhale auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY" codewhale --provider fireworks --model deepseek-v4-pro +# Moonshot/Kimi +codewhale auth set --provider moonshot --api-key "YOUR_MOONSHOT_OR_KIMI_API_KEY" +codewhale --provider moonshot --model kimi-k2.6 + # Generic OpenAI-compatible endpoint codewhale auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY" -OPENAI_BASE_URL="https://openai-compatible.example/v4" codewhale --provider openai --model glm-5 +OPENAI_BASE_URL="https://openai-compatible.example/v4" codewhale --provider openai --model deepseek-v4-pro # Self-hosted SGLang SGLANG_BASE_URL="http://localhost:30000/v1" codewhale --provider sglang --model deepseek-v4-flash @@ -429,11 +433,6 @@ ACP workflows outside the built-in Zed slice. | `@path` | Attach file/directory context in composer | | `↑` (at composer start) | Select attachment row for removal | -Voice input is available from the command palette (`Ctrl+K`, then search -`Voice input`) after configuring `voice_input_command`; the helper -records/transcribes audio, CodeWhale shows a listening status while it runs, and -the final transcript is inserted into the composer for editing. - Full shortcut catalog: [docs/KEYBINDINGS.md](docs/KEYBINDINGS.md). --- @@ -467,14 +466,15 @@ Key environment variables: | `DEEPSEEK_HTTP_HEADERS` | Optional custom model request headers, e.g. `X-Model-Provider-Id=your-model-provider` | | `DEEPSEEK_MODEL` | Default model | | `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | Stream idle timeout in seconds, default `300`, clamped to `1..=3600` | -| `DEEPSEEK_PROVIDER` | `codewhale` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `sglang`, `vllm`, `ollama` | +| `DEEPSEEK_PROVIDER` | `codewhale` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `moonshot`, `sglang`, `vllm`, `ollama` | | `DEEPSEEK_PROFILE` | Config profile name | | `DEEPSEEK_MEMORY` | Set to `on` to enable user memory | | `DEEPSEEK_ALLOW_INSECURE_HTTP=1` | Allow non-local `http://` API base URLs on trusted networks | -| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | Provider auth | +| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `MOONSHOT_API_KEY` / `KIMI_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | Provider auth | | `OPENAI_BASE_URL` / `OPENAI_MODEL` | Generic OpenAI-compatible endpoint and model ID | | `ATLASCLOUD_BASE_URL` / `ATLASCLOUD_MODEL` | AtlasCloud endpoint and model override | | `WANJIE_ARK_BASE_URL` / `WANJIE_ARK_MODEL` | Wanjie Ark endpoint and model override | +| `MOONSHOT_BASE_URL` / `KIMI_BASE_URL` / `MOONSHOT_MODEL` / `KIMI_MODEL` | Moonshot/Kimi endpoint and model override | | `OPENROUTER_BASE_URL` | OpenRouter endpoint override | | `NOVITA_BASE_URL` | Novita endpoint override | | `FIREWORKS_BASE_URL` | Fireworks endpoint override | @@ -604,7 +604,7 @@ This project ships with help from a growing community of contributors: - **[zichen0116](https://github.com/zichen0116)** — CODE_OF_CONDUCT.md (#686) - **[dfwqdyl-ui](https://github.com/dfwqdyl-ui)** — model ID case-sensitivity compatibility report (#729) - **[Oliver-ZPLiu](https://github.com/Oliver-ZPLiu)** — stale `working...` state bug report, Windows clipboard fallback, MCP Streamable HTTP session fixes, and Homebrew tap automation (#738, #850, #1643, #1631) -- **[reidliu41](https://github.com/reidliu41)** — resume hint, workspace trust persistence, Ollama provider support, thinking-block stream finalization, CI cache hardening, streaming wrap, DeepSeek model completions, and help picker selection polish (#863, #870, #921, #1078, #1603, #1628, #1601, #1964) +- **[reidliu41](https://github.com/reidliu41)** — resume hint, workspace trust persistence, Ollama provider support, thinking-block stream finalization, CI cache hardening, streaming wrap, DeepSeek model completions, help picker selection polish, and transcript user-message highlighting (#863, #870, #921, #1078, #1603, #1628, #1601, #1964, #1995) - **[cyq1017](https://github.com/cyq1017)** — Unicode `git_status` paths, local/configured skill discovery, and mode-switch toast dedupe (#1953, #1956, #1957) - **[xieshutao](https://github.com/xieshutao)** — plain Markdown skill fallback (#869) - **[GK012](https://github.com/GK012)** — npm wrapper `--version` fallback (#885) @@ -637,7 +637,7 @@ This project ships with help from a growing community of contributors: - **[mdrkrg](https://github.com/mdrkrg)** — first-run onboarding crash fix when the API key is missing (#1598) - **[Aitensa](https://github.com/Aitensa)** — CJK wrapping propagation for diff and pager output (#1622) - **[qiyan233](https://github.com/qiyan233)** — legacy DeepSeek CN provider alias compatibility (#1645) -- **[zlh124](https://github.com/zlh124)** — WSL2/headless startup report and clipboard-init fix (#1772, #1773) +- **[zlh124](https://github.com/zlh124)** — WSL2/headless startup report, clipboard-init fix, and YAML block-scalar frontmatter parsing (#1772, #1773, #1908) - **[aboimpinto](https://github.com/aboimpinto)** — Windows alt-screen logging, Home/End composer, and runtime log follow-ups (#1774, #1776, #1748, #1749, #1782, #1783) - **[LeoLin990405](https://github.com/LeoLin990405)** — provider model passthrough, reasoning replay, thinking-only turn, and Windows quoting fixes (#1740, #1743, #1742, #1744) - **[nightt5879](https://github.com/nightt5879)** — Ctrl+C prompt restore fix (#1764) @@ -707,6 +707,7 @@ This project ships with help from a growing community of contributors: - **[xulongzhe](https://github.com/xulongzhe)** — issue-template and vision-boundary follow-ups (#1530, #1544) - **[YaYII](https://github.com/YaYII)** — trusted media path work (#1462) - **[47Cid](https://github.com/47Cid)** and **[Jafar Akhondali](https://github.com/JafarAkhondali)** — responsible security disclosures and hardening reports +- **[gaord](https://github.com/gaord)** — approval-remember live-turn sync fix (#2041) --- diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index c6d4fd3f..dc402892 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -7,5 +7,5 @@ repository.workspace = true description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture" [dependencies] -codewhale-config = { path = "../config", version = "0.8.44" } +codewhale-config = { path = "../config", version = "0.8.45" } serde.workspace = true diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index c20bb618..349951ce 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -74,18 +74,18 @@ impl Default for ModelRegistry { supports_reasoning: true, }, ModelInfo { - id: "gpt-4.1".to_string(), + id: "deepseek-v4-pro".to_string(), provider: ProviderKind::Openai, - aliases: vec!["gpt4.1".to_string(), "gpt-4o".to_string()], + aliases: vec!["openai-compatible-deepseek-v4-pro".to_string()], supports_tools: true, supports_reasoning: true, }, ModelInfo { - id: "gpt-4.1-mini".to_string(), + id: "deepseek-v4-flash".to_string(), provider: ProviderKind::Openai, - aliases: vec!["gpt-4o-mini".to_string()], + aliases: vec!["openai-compatible-deepseek-v4-flash".to_string()], supports_tools: true, - supports_reasoning: false, + supports_reasoning: true, }, ModelInfo { id: "deepseek-reasoner".to_string(), diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index dc87c887..d683abf4 100644 --- a/crates/app-server/Cargo.toml +++ b/crates/app-server/Cargo.toml @@ -10,16 +10,21 @@ description = "Codex-style app-server transport for DeepSeek workspace architect anyhow.workspace = true axum.workspace = true clap.workspace = true -codewhale-agent = { path = "../agent", version = "0.8.44" } -codewhale-config = { path = "../config", version = "0.8.44" } -codewhale-core = { path = "../core", version = "0.8.44" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.44" } -codewhale-hooks = { path = "../hooks", version = "0.8.44" } -codewhale-mcp = { path = "../mcp", version = "0.8.44" } -codewhale-protocol = { path = "../protocol", version = "0.8.44" } -codewhale-state = { path = "../state", version = "0.8.44" } -codewhale-tools = { path = "../tools", version = "0.8.44" } +codewhale-agent = { path = "../agent", version = "0.8.45" } +codewhale-config = { path = "../config", version = "0.8.45" } +codewhale-core = { path = "../core", version = "0.8.45" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.45" } +codewhale-hooks = { path = "../hooks", version = "0.8.45" } +codewhale-mcp = { path = "../mcp", version = "0.8.45" } +codewhale-protocol = { path = "../protocol", version = "0.8.45" } +codewhale-state = { path = "../state", version = "0.8.45" } +codewhale-tools = { path = "../tools", version = "0.8.45" } serde.workspace = true serde_json.workspace = true tokio.workspace = true tower-http.workspace = true +uuid.workspace = true + +[dev-dependencies] +tempfile = "3.16" +tower = "0.5" diff --git a/crates/app-server/src/lib.rs b/crates/app-server/src/lib.rs index e580ed32..a9fe4399 100644 --- a/crates/app-server/src/lib.rs +++ b/crates/app-server/src/lib.rs @@ -2,8 +2,11 @@ use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; -use anyhow::Result; -use axum::extract::State; +use anyhow::{Result, bail}; +use axum::extract::{Request, State}; +use axum::http::{HeaderValue, Method, StatusCode, header}; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; use codewhale_agent::ModelRegistry; @@ -23,11 +26,25 @@ use serde_json::{Value, json}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::sync::{Mutex, RwLock}; use tower_http::cors::CorsLayer; +use uuid::Uuid; + +const DEFAULT_CORS_ORIGINS: &[&str] = &[ + "http://localhost", + "http://localhost:1420", + "http://localhost:3000", + "http://localhost:5173", + "http://127.0.0.1", + "http://127.0.0.1:1420", + "tauri://localhost", +]; #[derive(Debug, Clone)] pub struct AppServerOptions { pub listen: SocketAddr, pub config_path: Option, + pub auth_token: Option, + pub insecure_no_auth: bool, + pub cors_origins: Vec, } #[derive(Clone)] @@ -36,6 +53,7 @@ struct AppState { config: Arc>, runtime: Arc>, registry: ModelRegistry, + auth_token: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -69,6 +87,12 @@ struct StdioDispatchResult { should_exit: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AppTransport { + Http, + Stdio, +} + #[derive(Debug, Deserialize)] struct ConfigGetParams { key: String, @@ -92,26 +116,37 @@ struct ThreadMessageParams { } pub async fn run(options: AppServerOptions) -> Result<()> { - let state = build_state(options.config_path.clone())?; - - let app = Router::new() - .route("/healthz", get(healthz)) - .route("/thread", post(thread_handler)) - .route("/app", post(app_handler)) - .route("/prompt", post(prompt_handler)) - .route("/tool", post(tool_handler)) - .route("/jobs", get(jobs_handler)) - .route("/mcp/startup", post(mcp_startup_handler)) - .layer(CorsLayer::permissive()) - .with_state(state); + let auth_token = resolve_auth_token(&options)?; + let state = build_state(options.config_path.clone(), auth_token)?; + let app = app_router(state, &options.cors_origins); let listener = tokio::net::TcpListener::bind(options.listen).await?; axum::serve(listener, app).await?; Ok(()) } +fn app_router(state: AppState, cors_origins: &[String]) -> Router { + let protected_routes = Router::new() + .route("/thread", post(thread_handler)) + .route("/app", post(app_handler)) + .route("/prompt", post(prompt_handler)) + .route("/tool", post(tool_handler)) + .route("/jobs", get(jobs_handler)) + .route("/mcp/startup", post(mcp_startup_handler)) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_app_server_token, + )); + + Router::new() + .route("/healthz", get(healthz)) + .merge(protected_routes) + .layer(cors_layer(cors_origins)) + .with_state(state) +} + pub async fn run_stdio(config_path: Option) -> Result<()> { - let state = build_state(config_path)?; + let state = build_state(config_path, None)?; let stdin = tokio::io::stdin(); let stdout = tokio::io::stdout(); let mut reader = BufReader::new(stdin).lines(); @@ -258,10 +293,10 @@ async fn app_handler( State(state): State, Json(req): Json, ) -> Json { - Json(process_app_request(&state, req).await) + Json(process_app_request(&state, req, AppTransport::Http).await) } -fn build_state(config_path: Option) -> Result { +fn build_state(config_path: Option, auth_token: Option) -> Result { let store = ConfigStore::load(config_path.clone())?; let config = store.config.clone(); let registry = ModelRegistry::default(); @@ -294,9 +329,95 @@ fn build_state(config_path: Option) -> Result { config: Arc::new(RwLock::new(config)), runtime: Arc::new(Mutex::new(runtime)), registry, + auth_token, }) } +fn resolve_auth_token(options: &AppServerOptions) -> Result> { + let configured = options.auth_token.as_ref().map(|token| token.trim()); + if let Some(token) = configured + && token.is_empty() + { + bail!("app-server auth token cannot be empty"); + } + + if options.insecure_no_auth { + if !options.listen.ip().is_loopback() { + bail!("refusing unauthenticated app-server bind on non-loopback address"); + } + eprintln!("warning: app-server HTTP auth disabled by --insecure-no-auth"); + return Ok(None); + } + + let token = configured + .map(str::to_string) + .unwrap_or_else(|| format!("cwapp_{}", Uuid::new_v4().simple())); + if options.auth_token.is_some() { + eprintln!("app-server auth: bearer token required for HTTP routes."); + } else { + eprintln!("app-server auth: generated bearer token for this process."); + eprintln!(" Authorization: Bearer {token}"); + eprintln!(" Pass --auth-token or set CODEWHALE_APP_SERVER_TOKEN for a stable token."); + } + Ok(Some(token)) +} + +fn cors_layer(extra_origins: &[String]) -> CorsLayer { + let mut origins: Vec = DEFAULT_CORS_ORIGINS + .iter() + .filter_map(|origin| HeaderValue::from_str(origin).ok()) + .collect(); + for raw in extra_origins { + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + match HeaderValue::from_str(trimmed) { + Ok(value) if !origins.contains(&value) => origins.push(value), + Ok(_) => {} + Err(err) => { + eprintln!("warning: ignoring invalid app-server CORS origin `{trimmed}`: {err}") + } + } + } + + CorsLayer::new() + .allow_origin(origins) + .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) + .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]) +} + +async fn require_app_server_token( + State(state): State, + req: Request, + next: Next, +) -> Response { + let Some(expected) = state.auth_token.as_deref() else { + return next.run(req).await; + }; + let authorized = req + .headers() + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|raw| raw.strip_prefix("Bearer ")) + .is_some_and(|token| token == expected); + + if authorized { + next.run(req).await + } else { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": { + "message": "app-server bearer token required", + "status": StatusCode::UNAUTHORIZED.as_u16(), + } + })), + ) + .into_response() + } +} + fn params_or_object(params: Value) -> Value { if params.is_null() { json!({}) } else { params } } @@ -585,7 +706,8 @@ async fn dispatch_stdio_request( } } "app/capabilities" => { - let response = process_app_request(state, AppRequest::Capabilities).await; + let response = + process_app_request(state, AppRequest::Capabilities, AppTransport::Stdio).await; StdioDispatchResult { result: serde_json::to_value(response) .map_err(|err| JsonRpcError::internal(err.to_string()))?, @@ -594,7 +716,7 @@ async fn dispatch_stdio_request( } "app/request" => { let request: AppRequest = parse_params(params)?; - let response = process_app_request(state, request).await; + let response = process_app_request(state, request, AppTransport::Stdio).await; StdioDispatchResult { result: serde_json::to_value(response) .map_err(|err| JsonRpcError::internal(err.to_string()))?, @@ -603,8 +725,12 @@ async fn dispatch_stdio_request( } "app/config/get" => { let parsed: ConfigGetParams = parse_params(params_or_object(params))?; - let response = - process_app_request(state, AppRequest::ConfigGet { key: parsed.key }).await; + let response = process_app_request( + state, + AppRequest::ConfigGet { key: parsed.key }, + AppTransport::Stdio, + ) + .await; StdioDispatchResult { result: serde_json::to_value(response) .map_err(|err| JsonRpcError::internal(err.to_string()))?, @@ -619,6 +745,7 @@ async fn dispatch_stdio_request( key: parsed.key, value: parsed.value, }, + AppTransport::Stdio, ) .await; StdioDispatchResult { @@ -629,8 +756,12 @@ async fn dispatch_stdio_request( } "app/config/unset" => { let parsed: ConfigGetParams = parse_params(params_or_object(params))?; - let response = - process_app_request(state, AppRequest::ConfigUnset { key: parsed.key }).await; + let response = process_app_request( + state, + AppRequest::ConfigUnset { key: parsed.key }, + AppTransport::Stdio, + ) + .await; StdioDispatchResult { result: serde_json::to_value(response) .map_err(|err| JsonRpcError::internal(err.to_string()))?, @@ -638,7 +769,8 @@ async fn dispatch_stdio_request( } } "app/config/list" => { - let response = process_app_request(state, AppRequest::ConfigList).await; + let response = + process_app_request(state, AppRequest::ConfigList, AppTransport::Stdio).await; StdioDispatchResult { result: serde_json::to_value(response) .map_err(|err| JsonRpcError::internal(err.to_string()))?, @@ -646,7 +778,8 @@ async fn dispatch_stdio_request( } } "app/models" => { - let response = process_app_request(state, AppRequest::Models).await; + let response = + process_app_request(state, AppRequest::Models, AppTransport::Stdio).await; StdioDispatchResult { result: serde_json::to_value(response) .map_err(|err| JsonRpcError::internal(err.to_string()))?, @@ -654,7 +787,8 @@ async fn dispatch_stdio_request( } } "app/thread_loaded_list" | "app/thread-loaded-list" => { - let response = process_app_request(state, AppRequest::ThreadLoadedList).await; + let response = + process_app_request(state, AppRequest::ThreadLoadedList, AppTransport::Stdio).await; StdioDispatchResult { result: serde_json::to_value(response) .map_err(|err| JsonRpcError::internal(err.to_string()))?, @@ -685,7 +819,11 @@ async fn dispatch_stdio_request( Ok(outcome) } -async fn process_app_request(state: &AppState, req: AppRequest) -> AppResponse { +async fn process_app_request( + state: &AppState, + req: AppRequest, + transport: AppTransport, +) -> AppResponse { match req { AppRequest::Capabilities => AppResponse { ok: true, @@ -700,9 +838,13 @@ async fn process_app_request(state: &AppState, req: AppRequest) -> AppResponse { }, AppRequest::ConfigGet { key } => { let cfg = state.config.read().await; + let value = match transport { + AppTransport::Http => cfg.get_display_value(&key), + AppTransport::Stdio => cfg.get_value(&key), + }; AppResponse { ok: true, - data: json!({ "key": key, "value": cfg.get_value(&key) }), + data: json!({ "key": key, "value": value }), events: Vec::new(), } } @@ -781,3 +923,141 @@ async fn persist_config(state: &AppState, config: codewhale_config::ConfigToml) store.config = config; store.save() } + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::{Body, to_bytes}; + use codewhale_protocol::AppRequest; + use std::fs; + use tower::ServiceExt; + + fn app_with_config(auth_token: Option<&str>) -> (Router, tempfile::TempDir) { + let tmp = tempfile::tempdir().expect("tempdir"); + let config_path = tmp.path().join("config.toml"); + fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config"); + let state = build_state( + Some(config_path), + auth_token.map(std::string::ToString::to_string), + ) + .expect("state"); + (app_router(state, &[]), tmp) + } + + async fn response_body_json(response: Response) -> Value { + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body bytes"); + serde_json::from_slice(&bytes).expect("json response") + } + + #[tokio::test] + async fn http_app_routes_require_bearer_token_when_auth_enabled() { + let (app, _tmp) = app_with_config(Some("test-token")); + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/app") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&AppRequest::ConfigGet { + key: "api_key".to_string(), + }) + .expect("request json"), + )) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn http_config_get_redacts_sensitive_values_after_auth() { + let (app, _tmp) = app_with_config(Some("test-token")); + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/app") + .header(header::AUTHORIZATION, "Bearer test-token") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&AppRequest::ConfigGet { + key: "api_key".to_string(), + }) + .expect("request json"), + )) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = response_body_json(response).await; + assert_eq!(body["data"]["value"], "sk-d***cret"); + } + + #[tokio::test] + async fn cors_does_not_allow_arbitrary_origins() { + let (app, _tmp) = app_with_config(Some("test-token")); + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/healthz") + .header(header::ORIGIN, "https://attacker.example") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + assert!( + response + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .is_none() + ); + } + + #[test] + fn non_loopback_bind_without_auth_fails_fast() { + let options = AppServerOptions { + listen: "0.0.0.0:8787".parse().expect("socket addr"), + config_path: None, + auth_token: None, + insecure_no_auth: true, + cors_origins: Vec::new(), + }; + + let err = resolve_auth_token(&options).expect_err("non-loopback unauth should fail"); + assert!( + err.to_string() + .contains("refusing unauthenticated app-server bind") + ); + } + + #[tokio::test] + async fn stdio_transport_keeps_raw_config_get_for_legacy_clients() { + let state = build_state(None, None).expect("state"); + { + let mut cfg = state.config.write().await; + cfg.api_key = Some("sk-deepseek-secret".to_string()); + } + + let response = process_app_request( + &state, + AppRequest::ConfigGet { + key: "api_key".to_string(), + }, + AppTransport::Stdio, + ) + .await; + + assert_eq!(response.data["value"], "sk-deepseek-secret"); + } +} diff --git a/crates/app-server/src/main.rs b/crates/app-server/src/main.rs index fef6b65d..9627746e 100644 --- a/crates/app-server/src/main.rs +++ b/crates/app-server/src/main.rs @@ -17,6 +17,12 @@ struct Cli { port: u16, #[arg(long)] config: Option, + #[arg(long = "auth-token")] + auth_token: Option, + #[arg(long, default_value_t = false)] + insecure_no_auth: bool, + #[arg(long = "cors-origin")] + cors_origin: Vec, } #[tokio::main] @@ -28,6 +34,15 @@ async fn main() -> Result<()> { run(AppServerOptions { listen, config_path: cli.config, + auth_token: cli.auth_token.or_else(app_server_token_from_env), + insecure_no_auth: cli.insecure_no_auth, + cors_origins: cli.cors_origin, }) .await } + +fn app_server_token_from_env() -> Option { + std::env::var("CODEWHALE_APP_SERVER_TOKEN") + .ok() + .or_else(|| std::env::var("DEEPSEEK_APP_SERVER_TOKEN").ok()) +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 87ef1e74..1355ac04 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -25,13 +25,13 @@ path = "src/bin/deepseek_legacy_shim.rs" anyhow.workspace = true clap.workspace = true clap_complete.workspace = true -codewhale-agent = { path = "../agent", version = "0.8.44" } -codewhale-app-server = { path = "../app-server", version = "0.8.44" } -codewhale-config = { path = "../config", version = "0.8.44" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.44" } -codewhale-mcp = { path = "../mcp", version = "0.8.44" } -codewhale-secrets = { path = "../secrets", version = "0.8.44" } -codewhale-state = { path = "../state", version = "0.8.44" } +codewhale-agent = { path = "../agent", version = "0.8.45" } +codewhale-app-server = { path = "../app-server", version = "0.8.45" } +codewhale-config = { path = "../config", version = "0.8.45" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.45" } +codewhale-mcp = { path = "../mcp", version = "0.8.45" } +codewhale-secrets = { path = "../secrets", version = "0.8.45" } +codewhale-state = { path = "../state", version = "0.8.45" } chrono.workspace = true dirs.workspace = true serde.workspace = true diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 69afe196..2bd75181 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -182,7 +182,7 @@ working-tree diff. `export` only writes the current diff. Serve(TuiPassthroughArgs), /// Generate shell completions for the TUI binary. Completions(TuiPassthroughArgs), - /// Save a provider API key to the shared user config file. + /// Configure provider credentials. Login(LoginArgs), /// Remove saved authentication state. Logout, @@ -259,16 +259,10 @@ struct TuiPassthroughArgs { #[derive(Debug, Args)] struct LoginArgs { - #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)] - provider: ProviderArg, + #[arg(long, value_enum, hide = true)] + provider: Option, #[arg(long)] api_key: Option, - #[arg(long, default_value_t = false, hide = true)] - chatgpt: bool, - #[arg(long, default_value_t = false, hide = true)] - device_code: bool, - #[arg(long, hide = true)] - token: Option, } #[derive(Debug, Args)] @@ -428,6 +422,12 @@ struct AppServerArgs { port: u16, #[arg(long)] config: Option, + #[arg(long = "auth-token")] + auth_token: Option, + #[arg(long, default_value_t = false)] + insecure_no_auth: bool, + #[arg(long = "cors-origin")] + cors_origin: Vec, #[arg(long, default_value_t = false)] stdio: bool, } @@ -654,38 +654,9 @@ fn run_login_command_with_secrets( args: LoginArgs, secrets: &Secrets, ) -> Result<()> { - let provider: ProviderKind = args.provider.into(); + let provider: ProviderKind = args.provider.unwrap_or(ProviderArg::Deepseek).into(); store.config.provider = provider; - if args.chatgpt { - let token = match args.token { - Some(token) => token, - None => read_api_key_from_stdin()?, - }; - store.config.auth_mode = Some("chatgpt".to_string()); - store.config.chatgpt_access_token = Some(token); - store.config.device_code_session = None; - store.save()?; - println!("logged in using chatgpt token mode ({})", provider.as_str()); - return Ok(()); - } - - if args.device_code { - let token = match args.token { - Some(token) => token, - None => read_api_key_from_stdin()?, - }; - store.config.auth_mode = Some("device_code".to_string()); - store.config.device_code_session = Some(token); - store.config.chatgpt_access_token = None; - store.save()?; - println!( - "logged in using device code session mode ({})", - provider.as_str() - ); - return Ok(()); - } - let api_key = match args.api_key { Some(v) => v, None => read_api_key_from_stdin()?, @@ -721,8 +692,6 @@ fn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) - } clear_provider_api_key_from_keyring(secrets, active_provider); store.config.auth_mode = None; - store.config.chatgpt_access_token = None; - store.config.device_code_session = None; store.save()?; println!("logged out"); Ok(()) @@ -909,6 +878,10 @@ fn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec { vec![ format!("provider: {}", provider.as_str()), + format!( + "auth mode: {}", + store.config.auth_mode.as_deref().unwrap_or("api_key") + ), format!("active source: {active_label}"), "lookup order: config -> secret store -> env".to_string(), format!( @@ -1317,9 +1290,18 @@ fn run_app_server_command(args: AppServerArgs) -> Result<()> { runtime.block_on(run_app_server(AppServerOptions { listen, config_path: args.config, + auth_token: args.auth_token.or_else(app_server_token_from_env), + insecure_no_auth: args.insecure_no_auth, + cors_origins: args.cors_origin, })) } +fn app_server_token_from_env() -> Option { + std::env::var("CODEWHALE_APP_SERVER_TOKEN") + .ok() + .or_else(|| std::env::var("DEEPSEEK_APP_SERVER_TOKEN").ok()) +} + fn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> { let persisted = load_mcp_server_definitions(store); let updated = run_stdio_server(persisted)?; @@ -1484,6 +1466,9 @@ fn build_tui_command( cmd.env("DEEPSEEK_MODEL", &resolved_runtime.model); cmd.env("DEEPSEEK_BASE_URL", &resolved_runtime.base_url); cmd.env("DEEPSEEK_PROVIDER", resolved_runtime.provider.as_str()); + if let Some(auth_mode) = resolved_runtime.auth_mode.as_ref() { + cmd.env("DEEPSEEK_AUTH_MODE", auth_mode); + } if !resolved_runtime.http_headers.is_empty() { let encoded = resolved_runtime .http_headers @@ -2040,11 +2025,8 @@ mod tests { run_login_command_with_secrets( &mut store, LoginArgs { - provider: ProviderArg::Deepseek, + provider: Some(ProviderArg::Deepseek), api_key: Some("sk-test".to_string()), - chatgpt: false, - device_code: false, - token: None, }, &secrets, ) @@ -2566,7 +2548,7 @@ mod tests { "--profile", "work", "--model", - "gpt-4.1", + "deepseek-v4-pro", "--output-mode", "json", "--log-level", @@ -2578,7 +2560,7 @@ mod tests { "--sandbox-mode", "workspace-write", "--base-url", - "https://api.openai.com/v1", + "https://openai-compatible.example/v1", "--api-key", "sk-test", "--workspace", @@ -2588,19 +2570,22 @@ mod tests { "--skip-onboarding", "model", "resolve", - "gpt-4.1", + "deepseek-v4-pro", ]); assert!(matches!(cli.provider, Some(ProviderArg::Openai))); assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml"))); assert_eq!(cli.profile.as_deref(), Some("work")); - assert_eq!(cli.model.as_deref(), Some("gpt-4.1")); + assert_eq!(cli.model.as_deref(), Some("deepseek-v4-pro")); assert_eq!(cli.output_mode.as_deref(), Some("json")); assert_eq!(cli.log_level.as_deref(), Some("debug")); assert_eq!(cli.telemetry, Some(true)); assert_eq!(cli.approval_policy.as_deref(), Some("on-request")); assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write")); - assert_eq!(cli.base_url.as_deref(), Some("https://api.openai.com/v1")); + assert_eq!( + cli.base_url.as_deref(), + Some("https://openai-compatible.example/v1") + ); assert_eq!(cli.api_key.as_deref(), Some("sk-test")); assert_eq!(cli.workspace, Some(PathBuf::from("/tmp/workspace"))); assert!(cli.no_alt_screen); @@ -2668,6 +2653,10 @@ mod tests { command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(), Some("keyring") ); + assert_eq!( + command_env(&cmd, "DEEPSEEK_AUTH_MODE").as_deref(), + Some("api_key") + ); let args: Vec = cmd .get_args() .map(|arg| arg.to_string_lossy().into_owned()) diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 2d9ea522..912d5ed8 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -8,8 +8,9 @@ description = "Config schema and precedence model for DeepSeek workspace archite [dependencies] anyhow.workspace = true -codewhale-secrets = { path = "../secrets", version = "0.8.44" } +codewhale-secrets = { path = "../secrets", version = "0.8.45" } dirs.workspace = true serde.workspace = true +serde_json.workspace = true toml.workspace = true tracing.workspace = true diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index c1adc8da..576de517 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -17,7 +17,7 @@ pub const CONFIG_FILE_NAME: &str = "config.toml"; const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-v4-pro"; const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro"; const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash"; -const DEFAULT_OPENAI_MODEL: &str = "gpt-4.1"; +const DEFAULT_OPENAI_MODEL: &str = "deepseek-v4-pro"; const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta"; const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1"; const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; @@ -58,6 +58,7 @@ pub enum ProviderKind { )] Deepseek, NvidiaNim, + #[serde(alias = "open-ai")] Openai, Atlascloud, #[serde( @@ -210,8 +211,6 @@ pub struct ConfigToml { pub provider: ProviderKind, pub model: Option, pub auth_mode: Option, - pub chatgpt_access_token: Option, - pub device_code_session: Option, pub output_mode: Option, pub log_level: Option, pub telemetry: Option, @@ -343,91 +342,61 @@ pub struct LspConfigToml { } impl ConfigToml { - /// Merge project-level overrides from `$WORKSPACE/.deepseek/config.toml`. - /// Only populated fields in `project` are applied; everything else - /// keeps its global value. Provider-specific sub-tables are merged - /// field-by-field so a project can set just `providers.deepseek.model` - /// without needing to repeat `api_key` or `base_url`. + /// Merge safe project-level overrides from `$WORKSPACE/.codewhale/config.toml` + /// or legacy `$WORKSPACE/.deepseek/config.toml`. + /// + /// Repo-local config is untrusted input. This helper intentionally ignores + /// credentials, endpoints, provider selection, auth/session values, telemetry, + /// network policy, skill registry, LSP command tables, and unknown extras. + /// Approval and sandbox values may only tighten the existing user/global + /// posture. pub fn merge_project_overrides(&mut self, project: ConfigToml) { - // Check provider override condition before moving fields. - let has_api_key = project.api_key.is_some(); - - // Top-level scalar fields: apply when the project has a value. - if has_api_key { - self.api_key = project.api_key; - } - if project.base_url.is_some() { - self.base_url = project.base_url; - } - if !project.http_headers.is_empty() { - self.http_headers = project.http_headers; - } if project.default_text_model.is_some() { self.default_text_model = project.default_text_model; } if project.model.is_some() { self.model = project.model; } - if project.auth_mode.is_some() { - self.auth_mode = project.auth_mode; - } if project.output_mode.is_some() { self.output_mode = project.output_mode; } - if project.telemetry.is_some() { - self.telemetry = project.telemetry; + if project.log_level.is_some() { + self.log_level = project.log_level; } - if project.approval_policy.is_some() { - self.approval_policy = project.approval_policy; + if let Some(policy) = project.approval_policy + && project_approval_policy_is_allowed(self.approval_policy.as_deref(), &policy) + { + self.approval_policy = Some(policy); } - if project.sandbox_mode.is_some() { - self.sandbox_mode = project.sandbox_mode; - } - // Provider is only overridden if explicitly set (non-default). - if project.provider != ProviderKind::Deepseek || has_api_key { - self.provider = project.provider; + if let Some(mode) = project.sandbox_mode + && project_sandbox_mode_is_allowed(self.sandbox_mode.as_deref(), &mode) + { + self.sandbox_mode = Some(mode); } - // Merge provider sub-tables field-by-field. - merge_provider_config(&mut self.providers.deepseek, &project.providers.deepseek); - merge_provider_config( + merge_project_provider_config(&mut self.providers.deepseek, &project.providers.deepseek); + merge_project_provider_config( &mut self.providers.nvidia_nim, &project.providers.nvidia_nim, ); - merge_provider_config(&mut self.providers.openai, &project.providers.openai); - merge_provider_config( + merge_project_provider_config(&mut self.providers.openai, &project.providers.openai); + merge_project_provider_config( &mut self.providers.atlascloud, &project.providers.atlascloud, ); - merge_provider_config( + merge_project_provider_config( &mut self.providers.wanjie_ark, &project.providers.wanjie_ark, ); - merge_provider_config( + merge_project_provider_config( &mut self.providers.openrouter, &project.providers.openrouter, ); - merge_provider_config(&mut self.providers.novita, &project.providers.novita); - merge_provider_config(&mut self.providers.fireworks, &project.providers.fireworks); - merge_provider_config(&mut self.providers.sglang, &project.providers.sglang); - merge_provider_config(&mut self.providers.vllm, &project.providers.vllm); - merge_provider_config(&mut self.providers.ollama, &project.providers.ollama); - - if project.network.is_some() { - self.network = project.network; - } - if project.skills.is_some() { - self.skills = project.skills; - } - if project.snapshots.is_some() { - self.snapshots = project.snapshots; - } - if project.lsp.is_some() { - self.lsp = project.lsp; - } - for (k, v) in project.extras { - self.extras.insert(k, v); - } + merge_project_provider_config(&mut self.providers.novita, &project.providers.novita); + merge_project_provider_config(&mut self.providers.fireworks, &project.providers.fireworks); + merge_project_provider_config(&mut self.providers.sglang, &project.providers.sglang); + merge_project_provider_config(&mut self.providers.vllm, &project.providers.vllm); + merge_project_provider_config(&mut self.providers.ollama, &project.providers.ollama); } #[must_use] @@ -440,8 +409,6 @@ impl ConfigToml { "default_text_model" => self.default_text_model.clone(), "model" => self.model.clone(), "auth.mode" => self.auth_mode.clone(), - "auth.chatgpt_access_token" => self.chatgpt_access_token.clone(), - "auth.device_code_session" => self.device_code_session.clone(), "output_mode" => self.output_mode.clone(), "log_level" => self.log_level.clone(), "telemetry" => self.telemetry.map(|v| v.to_string()), @@ -540,8 +507,6 @@ impl ConfigToml { "default_text_model" => self.default_text_model = Some(value.to_string()), "model" => self.model = Some(value.to_string()), "auth.mode" => self.auth_mode = Some(value.to_string()), - "auth.chatgpt_access_token" => self.chatgpt_access_token = Some(value.to_string()), - "auth.device_code_session" => self.device_code_session = Some(value.to_string()), "output_mode" => self.output_mode = Some(value.to_string()), "log_level" => self.log_level = Some(value.to_string()), "telemetry" => { @@ -700,8 +665,6 @@ impl ConfigToml { "default_text_model" => self.default_text_model = None, "model" => self.model = None, "auth.mode" => self.auth_mode = None, - "auth.chatgpt_access_token" => self.chatgpt_access_token = None, - "auth.device_code_session" => self.device_code_session = None, "output_mode" => self.output_mode = None, "log_level" => self.log_level = None, "telemetry" => self.telemetry = None, @@ -795,12 +758,6 @@ impl ConfigToml { if let Some(v) = self.auth_mode.as_ref() { out.insert("auth.mode".to_string(), v.clone()); } - if let Some(v) = self.chatgpt_access_token.as_ref() { - out.insert("auth.chatgpt_access_token".to_string(), redact_secret(v)); - } - if let Some(v) = self.device_code_session.as_ref() { - out.insert("auth.device_code_session".to_string(), redact_secret(v)); - } if let Some(v) = self.output_mode.as_ref() { out.insert("output_mode".to_string(), v.clone()); } @@ -1137,18 +1094,57 @@ impl ConfigToml { } } -fn merge_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) { - if source.api_key.is_some() { - target.api_key = source.api_key.clone(); - } - if source.base_url.is_some() { - target.base_url = source.base_url.clone(); - } +fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) { if source.model.is_some() { target.model = source.model.clone(); } - if !source.http_headers.is_empty() { - target.http_headers = source.http_headers.clone(); +} + +#[must_use] +pub fn project_approval_policy_is_allowed(current: Option<&str>, project: &str) -> bool { + let Some(project_rank) = approval_policy_rank(project) else { + return false; + }; + match current.and_then(approval_policy_rank) { + Some(current_rank) => project_rank >= current_rank, + None => project_rank >= 2, + } +} + +#[must_use] +pub fn project_sandbox_mode_is_allowed(current: Option<&str>, project: &str) -> bool { + let normalized_project = project.trim().to_ascii_lowercase(); + if normalized_project == "external-sandbox" { + return current + .map(|value| value.trim().eq_ignore_ascii_case("external-sandbox")) + .unwrap_or(false); + } + + let Some(project_rank) = sandbox_mode_rank(project) else { + return false; + }; + match current.and_then(sandbox_mode_rank) { + Some(current_rank) => project_rank >= current_rank, + None => project_rank >= 2, + } +} + +fn approval_policy_rank(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "auto" => Some(0), + "suggest" | "suggested" | "on-request" | "untrusted" => Some(1), + "never" | "deny" | "denied" => Some(2), + _ => None, + } +} + +fn sandbox_mode_rank(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "danger-full-access" => Some(0), + "external-sandbox" => Some(0), + "workspace-write" => Some(1), + "read-only" => Some(2), + _ => None, } } @@ -1686,10 +1682,7 @@ fn redact_secret(secret: &str) -> String { #[must_use] pub fn is_sensitive_config_key(key: &str) -> bool { - matches!( - key, - "api_key" | "auth.chatgpt_access_token" | "auth.device_code_session" - ) || key.ends_with(".api_key") + key == "api_key" || key.ends_with(".api_key") } fn normalize_config_file_path(path: PathBuf) -> Result { @@ -2344,7 +2337,6 @@ mod tests { fn get_display_value_redacts_sensitive_keys() { let mut config = ConfigToml { api_key: Some("sk-deepseek-secret".to_string()), - chatgpt_access_token: Some("chatgpt-access-secret".to_string()), ..ConfigToml::default() }; config.providers.openrouter.api_key = Some("openrouter-secret-value".to_string()); @@ -2354,12 +2346,6 @@ mod tests { config.get_display_value("api_key").as_deref(), Some("sk-d***cret") ); - assert_eq!( - config - .get_display_value("auth.chatgpt_access_token") - .as_deref(), - Some("chat***cret") - ); assert_eq!( config .get_display_value("providers.openrouter.api_key") @@ -2372,6 +2358,87 @@ mod tests { ); } + #[test] + fn project_merge_denies_credentials_endpoints_and_provider_selection() { + let mut base = ConfigToml { + provider: ProviderKind::Deepseek, + api_key: Some("user-key".to_string()), + base_url: Some("https://api.deepseek.com".to_string()), + default_text_model: Some("deepseek-v4-flash".to_string()), + ..ConfigToml::default() + }; + base.providers.openrouter.api_key = Some("user-openrouter-key".to_string()); + + let mut project = ConfigToml { + provider: ProviderKind::Openrouter, + api_key: Some("attacker-key".to_string()), + base_url: Some("https://evil.example/v1".to_string()), + default_text_model: Some("deepseek-v4-pro".to_string()), + auth_mode: Some("oauth".to_string()), + telemetry: Some(true), + ..ConfigToml::default() + }; + project.providers.openrouter.api_key = Some("attacker-openrouter-key".to_string()); + project.providers.openrouter.base_url = Some("https://evil.example/openrouter".to_string()); + project.providers.openrouter.model = Some("deepseek/deepseek-v4-pro".to_string()); + + base.merge_project_overrides(project); + + assert_eq!(base.provider, ProviderKind::Deepseek); + assert_eq!(base.api_key.as_deref(), Some("user-key")); + assert_eq!(base.base_url.as_deref(), Some("https://api.deepseek.com")); + assert_eq!(base.auth_mode, None); + assert_eq!(base.telemetry, None); + assert_eq!( + base.providers.openrouter.api_key.as_deref(), + Some("user-openrouter-key") + ); + assert_eq!(base.providers.openrouter.base_url, None); + assert_eq!(base.default_text_model.as_deref(), Some("deepseek-v4-pro")); + assert_eq!( + base.providers.openrouter.model.as_deref(), + Some("deepseek/deepseek-v4-pro") + ); + } + + #[test] + fn project_merge_only_tightens_approval_and_sandbox_policy() { + let mut strict = ConfigToml { + approval_policy: Some("never".to_string()), + sandbox_mode: Some("read-only".to_string()), + ..ConfigToml::default() + }; + strict.merge_project_overrides(ConfigToml { + approval_policy: Some("on-request".to_string()), + sandbox_mode: Some("workspace-write".to_string()), + ..ConfigToml::default() + }); + assert_eq!(strict.approval_policy.as_deref(), Some("never")); + assert_eq!(strict.sandbox_mode.as_deref(), Some("read-only")); + + let mut permissive = ConfigToml { + approval_policy: Some("auto".to_string()), + sandbox_mode: Some("workspace-write".to_string()), + ..ConfigToml::default() + }; + permissive.merge_project_overrides(ConfigToml { + approval_policy: Some("never".to_string()), + sandbox_mode: Some("read-only".to_string()), + ..ConfigToml::default() + }); + assert_eq!(permissive.approval_policy.as_deref(), Some("never")); + assert_eq!(permissive.sandbox_mode.as_deref(), Some("read-only")); + + let mut unset = ConfigToml::default(); + unset.merge_project_overrides(ConfigToml { + approval_policy: Some("on-request".to_string()), + sandbox_mode: Some("workspace-write".to_string()), + ..ConfigToml::default() + }); + assert_eq!(unset.approval_policy, None); + assert_eq!(unset.sandbox_mode, None); + } + #[test] fn list_values_redacts_unicode_api_key_without_byte_slicing() { let config = ConfigToml { diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index debdf425..c9d602f4 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -9,13 +9,13 @@ description = "Core runtime boundaries for DeepSeek workspace architecture" [dependencies] anyhow.workspace = true chrono.workspace = true -codewhale-agent = { path = "../agent", version = "0.8.44" } -codewhale-config = { path = "../config", version = "0.8.44" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.44" } -codewhale-hooks = { path = "../hooks", version = "0.8.44" } -codewhale-mcp = { path = "../mcp", version = "0.8.44" } -codewhale-protocol = { path = "../protocol", version = "0.8.44" } -codewhale-state = { path = "../state", version = "0.8.44" } -codewhale-tools = { path = "../tools", version = "0.8.44" } +codewhale-agent = { path = "../agent", version = "0.8.45" } +codewhale-config = { path = "../config", version = "0.8.45" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.45" } +codewhale-hooks = { path = "../hooks", version = "0.8.45" } +codewhale-mcp = { path = "../mcp", version = "0.8.45" } +codewhale-protocol = { path = "../protocol", version = "0.8.45" } +codewhale-state = { path = "../state", version = "0.8.45" } +codewhale-tools = { path = "../tools", version = "0.8.45" } serde_json.workspace = true uuid.workspace = true diff --git a/crates/execpolicy/Cargo.toml b/crates/execpolicy/Cargo.toml index 669759c4..16b09697 100644 --- a/crates/execpolicy/Cargo.toml +++ b/crates/execpolicy/Cargo.toml @@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace [dependencies] anyhow.workspace = true -codewhale-protocol = { path = "../protocol", version = "0.8.44" } +codewhale-protocol = { path = "../protocol", version = "0.8.45" } serde.workspace = true diff --git a/crates/hooks/Cargo.toml b/crates/hooks/Cargo.toml index a39dc18f..4f657cd0 100644 --- a/crates/hooks/Cargo.toml +++ b/crates/hooks/Cargo.toml @@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc anyhow.workspace = true async-trait.workspace = true chrono.workspace = true -codewhale-protocol = { path = "../protocol", version = "0.8.44" } +codewhale-protocol = { path = "../protocol", version = "0.8.45" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 9059c344..464ce47e 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral [dependencies] anyhow.workspace = true async-trait.workspace = true -codewhale-protocol = { path = "../protocol", version = "0.8.44" } +codewhale-protocol = { path = "../protocol", version = "0.8.45" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index f6fda0e4..47c20bd9 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,6 +7,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.45] - 2026-05-25 + +### Added + +- **RLM session objects.** `rlm_open` can now load `session://` refs, + exposing the active prompt, history, and session data as symbolic objects + inside RLM REPLs (#2047). +- **Deterministic whale-species sub-agent names.** Sub-agents now get stable, + human-readable whale-species nicknames (e.g. "Beluga", "Orca") while + preserving the raw agent ID in the popup (#2035, #2016). +- **`/balance` command scaffold.** Registered the `/balance` slash command + as a placeholder for future provider billing queries (#2035, #2019). +- **Readable `/restore` snapshot labels.** Snapshot labels now include the + originating user prompt so restore listings are easier to identify. Thanks + @idling11 (#2111). +- **Sidebar hover tooltips.** Truncated Work and Tasks sidebar lines now expose + their full text on hover. Thanks @idling11 (#2110). + +### Changed + +- **AGENTS.md is now maintainer-local.** The project instructions file no + longer ships as a tracked repo file; it lives in maintainer-local ignored + state (#2047). + +### Fixed + +- **Sub-agent completion handoff compatibility.** Completion handoffs now use a + chat-template-safe role and emit before terminal updates, fixing strict + OpenAI-compatible/self-hosted backends and preserving transcript ordering. + Thanks @h3c-hexin and @cyq1017 (#2057, #2120). +- **Self-hosted context budgeting.** Sub-500K self-hosted model windows now keep + a usable input budget instead of disabling preflight compaction after output + reservation underflow. Thanks @h3c-hexin (#2060). +- **Goal prompts start actionable.** Goal-start prompts now open in an + actionable state instead of requiring an extra nudge. Thanks @cyq1017 + (#2097). +- **Composer session title display.** The composer chrome shows the current + session title again and avoids grayscale luma overflow in debug builds. + Thanks @wdw8276 (#2108). +- **Approval prompts use a one-step confirmation flow.** Enter now commits the + selected approval option directly, destructive warnings remain visible, and + abort cancels the active turn instead of only denying the current tool call. + Thanks @reidliu41 (#2143). +- **Model picker selection survives Esc.** Dismissing the model picker with Esc + no longer loses the highlighted selection. Thanks @reidliu41 (#2056). +- **Slash recovery no longer restores command tails in the composer.** + Resuming a session or recovering from a crash no longer leaves stale + slash-command text (e.g. `/sessions`) in the composer input (#2047, #2032). +- **Remembered tool approvals now update the live active turn.** + When the "remember" checkbox is set on an approval dialog, the active + turn's auto-approve flag flips immediately instead of waiting for the + next turn. Thanks @gaord (#2047, #2041). +- **YAML block scalars in SKILL.md frontmatter.** Multi-line descriptions + using `>` or `|` indicators are now parsed correctly — folded block + scalars join non-empty lines with spaces, literal scalars preserve + newlines, and all three chomping modes (strip/clip/keep) are supported. + Thanks @zlh124 (#1908, #1907). +- **User messages highlighted in the transcript.** User-authored messages + now render with a full-row background in the live TUI transcript, making + it easier to scan prior turns. Assistant and system messages are + unaffected. Thanks @reidliu41 (#1995, #1672). +- **Cancellable `list_dir` and `file_search`.** Long directory walks and + file searches now respond to user cancel/stop requests with a 30-second + fallback timeout, preventing the TUI from hanging on deep or slow + filesystems (#2035). + ## [0.8.44] - 2026-05-24 ### Added @@ -4806,7 +4872,8 @@ Welcome — and thank you. - Hooks system and config profiles - Example skills and launch assets -[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.44...HEAD +[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.45...HEAD +[0.8.45]: https://github.com/Hmbown/CodeWhale/compare/v0.8.44...v0.8.45 [0.8.44]: https://github.com/Hmbown/CodeWhale/compare/v0.8.43...v0.8.44 [0.8.43]: https://github.com/Hmbown/CodeWhale/compare/v0.8.42...v0.8.43 [0.8.42]: https://github.com/Hmbown/CodeWhale/compare/v0.8.41...v0.8.42 diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 63720042..545eb5e8 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -27,9 +27,9 @@ path = "src/bin/deepseek_tui_legacy_shim.rs" [dependencies] anyhow = "1.0.100" arboard = "3.4" -codewhale-config = { path = "../config", version = "0.8.44" } -codewhale-secrets = { path = "../secrets", version = "0.8.44" } -codewhale-tools = { path = "../tools", version = "0.8.44" } +codewhale-config = { path = "../config", version = "0.8.45" } +codewhale-secrets = { path = "../secrets", version = "0.8.45" } +codewhale-tools = { path = "../tools", version = "0.8.45" } schemaui = { version = "0.12.0", default-features = false, optional = true } async-stream = "0.3.6" async-trait = "0.1" diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 12926b70..7e93e9d7 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -37,7 +37,7 @@ pub const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta"; pub const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro"; pub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash"; pub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1"; -pub const DEFAULT_OPENAI_MODEL: &str = "gpt-4.1"; +pub const DEFAULT_OPENAI_MODEL: &str = "deepseek-v4-pro"; pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; pub const DEFAULT_ATLASCLOUD_MODEL: &str = "deepseek-ai/deepseek-v4-flash"; pub const DEFAULT_ATLASCLOUD_BASE_URL: &str = "https://api.atlascloud.ai/v1"; diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index c53cdaca..be286978 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -69,6 +69,7 @@ mod snapshot; mod task_manager; #[cfg(test)] mod test_support; +mod theme_qa_audit; mod tools; mod tui; mod utils; @@ -4661,41 +4662,49 @@ fn merge_project_config(config: &mut Config, workspace: &Path) { // String fields a project may legitimately override (model, // approval/sandbox tightening, notes path, reasoning effort). - // Loosening *values* like `approval_policy = "auto"` and - // `sandbox_mode = "danger-full-access"` are denied unconditionally - // — those are pure escalation regardless of the user's prior - // value. Sub-tightening comparisons (e.g. user `"never"` → - // project `"on-request"`) stay v0.8.9 follow-up because they - // need a richer ordering check. for (key, field) in [ ("model", &mut config.default_text_model), ("reasoning_effort", &mut config.reasoning_effort), - ("approval_policy", &mut config.approval_policy), - ("sandbox_mode", &mut config.sandbox_mode), ("notes_path", &mut config.notes_path), ] { if let Some(v) = table.get(key).and_then(toml::Value::as_str) && !v.is_empty() { - // #417 escalation deny: project cannot push the session - // to the loosest values. Other strings flow through the - // existing config validator on load. - let is_escalation = matches!( - (key, v), - ("approval_policy", "auto") | ("sandbox_mode", "danger-full-access") - ); - if is_escalation { - eprintln!( - "warning: project-scope `{key} = \"{v}\"` is ignored — \ - project config cannot escalate to the loosest value. \ - (See #417.)" - ); - continue; - } *field = Some(v.to_string()); } } + if let Some(v) = table.get("approval_policy").and_then(toml::Value::as_str) + && !v.is_empty() + { + if codewhale_config::project_approval_policy_is_allowed( + config.approval_policy.as_deref(), + v, + ) { + config.approval_policy = Some(v.to_string()); + } else { + eprintln!( + "warning: project-scope `approval_policy = \"{v}\"` is ignored — \ + project config can only tighten the user's approval policy. \ + (See #417.)" + ); + } + } + + if let Some(v) = table.get("sandbox_mode").and_then(toml::Value::as_str) + && !v.is_empty() + { + if codewhale_config::project_sandbox_mode_is_allowed(config.sandbox_mode.as_deref(), v) { + config.sandbox_mode = Some(v.to_string()); + } else { + eprintln!( + "warning: project-scope `sandbox_mode = \"{v}\"` is ignored — \ + project config can only tighten the user's sandbox mode. \ + (See #417.)" + ); + } + } + // Numeric / bool fields that benefit from per-project overrides. if let Some(v) = table.get("max_subagents").and_then(toml::Value::as_integer) && v > 0 @@ -6299,6 +6308,42 @@ approval_policy = "auto" ); } + #[test] + fn project_overlay_preserves_user_policy_when_project_tries_intermediate_loosening() { + let tmp = workspace_with_project_config( + r#" +approval_policy = "on-request" +sandbox_mode = "workspace-write" +"#, + ); + let mut config = Config { + approval_policy: Some("never".to_string()), + sandbox_mode: Some("read-only".to_string()), + ..Config::default() + }; + merge_project_config(&mut config, tmp.path()); + assert_eq!(config.approval_policy.as_deref(), Some("never")); + assert_eq!(config.sandbox_mode.as_deref(), Some("read-only")); + } + + #[test] + fn project_overlay_can_tighten_user_policy() { + let tmp = workspace_with_project_config( + r#" +approval_policy = "never" +sandbox_mode = "read-only" +"#, + ); + let mut config = Config { + approval_policy: Some("on-request".to_string()), + sandbox_mode: Some("workspace-write".to_string()), + ..Config::default() + }; + merge_project_config(&mut config, tmp.path()); + assert_eq!(config.approval_policy.as_deref(), Some("never")); + assert_eq!(config.sandbox_mode.as_deref(), Some("read-only")); + } + #[test] fn project_overlay_overrides_max_subagents_and_allow_shell() { let tmp = workspace_with_project_config( diff --git a/crates/tui/src/palette.rs b/crates/tui/src/palette.rs index 770308e9..2890804c 100644 --- a/crates/tui/src/palette.rs +++ b/crates/tui/src/palette.rs @@ -4,15 +4,57 @@ use ratatui::style::Color; #[cfg(target_os = "macos")] use std::process::Command; -pub const DEEPSEEK_BLUE_RGB: (u8, u8, u8) = (53, 120, 229); // #3578E5 -pub const DEEPSEEK_SKY_RGB: (u8, u8, u8) = (106, 174, 242); +// v0.8.45 Whale dark palette — refreshed ocean/navy identity. +pub const WHALE_BG_RGB: (u8, u8, u8) = (13, 21, 37); // #0D1525 Deep Navy +pub const WHALE_PANEL_RGB: (u8, u8, u8) = (19, 29, 48); // #131D30 +pub const WHALE_ELEVATED_RGB: (u8, u8, u8) = (26, 40, 64); // #1A2840 +pub const WHALE_SELECTION_RGB: (u8, u8, u8) = (30, 50, 82); // #1E3252 +pub const WHALE_TEXT_BODY_RGB: (u8, u8, u8) = (246, 242, 232); // #F6F2E8 Whale Ivory +pub const WHALE_TEXT_SOFT_RGB: (u8, u8, u8) = (217, 224, 234); // #D9E0EA +pub const WHALE_TEXT_MUTED_RGB: (u8, u8, u8) = (169, 180, 199); // #A9B4C7 Mist Gray +pub const WHALE_TEXT_HINT_RGB: (u8, u8, u8) = (122, 134, 158); // #7A869E +#[allow(dead_code)] +pub const WHALE_TEXT_DIM_RGB: (u8, u8, u8) = (107, 120, 146); // #6B7892 +pub const WHALE_ACCENT_PRIMARY_RGB: (u8, u8, u8) = (246, 196, 83); // #F6C453 Signal Gold +pub const WHALE_ACCENT_SECONDARY_RGB: (u8, u8, u8) = (79, 209, 197); // #4FD1C5 Seafoam +pub const WHALE_ACCENT_ACTION_RGB: (u8, u8, u8) = (255, 122, 89); // #FF7A59 Coral Spark +pub const WHALE_ERROR_RGB: (u8, u8, u8) = (255, 92, 122); // #FF5C7A Rose Red +pub const WHALE_ERROR_HOVER_RGB: (u8, u8, u8) = (255, 120, 144); // #FF7890 Rose Hover +pub const WHALE_ERROR_SURFACE_RGB: (u8, u8, u8) = (42, 18, 26); // #2A121A Error Surface +pub const WHALE_ERROR_BORDER_RGB: (u8, u8, u8) = (255, 138, 160); // #FF8AA0 Error Border +pub const WHALE_ERROR_TEXT_RGB: (u8, u8, u8) = (255, 214, 222); // #FFD6DE Error Text +pub const WHALE_WARNING_RGB: (u8, u8, u8) = (240, 160, 48); // #F0A030 +pub const WHALE_SUCCESS_RGB: (u8, u8, u8) = (79, 209, 197); // #4FD1C5 Seafoam +pub const WHALE_INFO_RGB: (u8, u8, u8) = (106, 174, 242); // #6AAEF2 Sky +pub const WHALE_BORDER_RGB: (u8, u8, u8) = (42, 74, 127); // #2A4A7F +pub const WHALE_REASONING_TEXT_RGB: (u8, u8, u8) = (224, 153, 72); // #E09948 +pub const WHALE_REASONING_SURFACE_RGB: (u8, u8, u8) = (42, 34, 24); // #2A2218 +pub const WHALE_REASONING_TINT_RGB: (u8, u8, u8) = (20, 30, 42); // #141E2A +pub const WHALE_DIFF_ADDED_RGB: (u8, u8, u8) = (87, 199, 133); // #57C785 +#[allow(dead_code)] +pub const WHALE_DIFF_DELETED_RGB: (u8, u8, u8) = (255, 92, 122); // #FF5C7A Rose Red +pub const WHALE_DIFF_ADDED_BG_RGB: (u8, u8, u8) = (18, 42, 34); // #122A22 +pub const WHALE_DIFF_DELETED_BG_RGB: (u8, u8, u8) = (42, 18, 26); // #2A121A +pub const WHALE_MODE_AGENT_RGB: (u8, u8, u8) = (80, 150, 255); // #5096FF +pub const WHALE_MODE_YOLO_RGB: (u8, u8, u8) = (255, 100, 100); // #FF6464 +pub const WHALE_MODE_PLAN_RGB: (u8, u8, u8) = (246, 196, 83); // #F6C453 Signal Gold +pub const WHALE_MODE_GOAL_RGB: (u8, u8, u8) = (100, 220, 160); // #64DCA0 +pub const WHALE_TOOL_LIVE_RGB: (u8, u8, u8) = (133, 184, 234); // #85B8EA +pub const WHALE_TOOL_ISSUE_RGB: (u8, u8, u8) = (192, 143, 153); // #C08F99 +pub const WHALE_TOOL_OUTPUT_RGB: (u8, u8, u8) = (194, 208, 224); // #C2D0E0 +pub const WHALE_TOOL_SURFACE_RGB: (u8, u8, u8) = (24, 34, 53); // #182235 +pub const WHALE_TOOL_ACTIVE_RGB: (u8, u8, u8) = (31, 45, 69); // #1F2D45 + +// Backward-compatible aliases for existing call sites. +pub const DEEPSEEK_BLUE_RGB: (u8, u8, u8) = WHALE_ACCENT_PRIMARY_RGB; +pub const DEEPSEEK_SKY_RGB: (u8, u8, u8) = WHALE_INFO_RGB; #[allow(dead_code)] pub const DEEPSEEK_AQUA_RGB: (u8, u8, u8) = (54, 187, 212); #[allow(dead_code)] pub const DEEPSEEK_NAVY_RGB: (u8, u8, u8) = (24, 63, 138); -pub const DEEPSEEK_INK_RGB: (u8, u8, u8) = (11, 21, 38); -pub const DEEPSEEK_SLATE_RGB: (u8, u8, u8) = (18, 28, 46); -pub const DEEPSEEK_RED_RGB: (u8, u8, u8) = (226, 80, 96); +pub const DEEPSEEK_INK_RGB: (u8, u8, u8) = WHALE_BG_RGB; +pub const DEEPSEEK_SLATE_RGB: (u8, u8, u8) = WHALE_PANEL_RGB; +pub const DEEPSEEK_RED_RGB: (u8, u8, u8) = WHALE_ERROR_RGB; pub const LIGHT_SURFACE_RGB: (u8, u8, u8) = (246, 248, 251); // #F6F8FB pub const LIGHT_PANEL_RGB: (u8, u8, u8) = (236, 242, 248); // #ECF2F8 @@ -40,13 +82,14 @@ pub const GRAYSCALE_BORDER_RGB: (u8, u8, u8) = (96, 96, 96); // #606060 pub const GRAYSCALE_SELECTION_RGB: (u8, u8, u8) = (62, 62, 62); // #3E3E3E // New semantic colors -pub const BORDER_COLOR_RGB: (u8, u8, u8) = (42, 74, 127); // #2A4A7F +pub const BORDER_COLOR_RGB: (u8, u8, u8) = WHALE_BORDER_RGB; // #2A4A7F pub const DEEPSEEK_BLUE: Color = Color::Rgb( DEEPSEEK_BLUE_RGB.0, DEEPSEEK_BLUE_RGB.1, DEEPSEEK_BLUE_RGB.2, ); +/// Now maps to the secondary accent (Seafoam) for backward compat. pub const DEEPSEEK_SKY: Color = Color::Rgb(DEEPSEEK_SKY_RGB.0, DEEPSEEK_SKY_RGB.1, DEEPSEEK_SKY_RGB.2); #[allow(dead_code)] @@ -181,13 +224,37 @@ pub const GRAYSCALE_SELECTION_BG: Color = Color::Rgb( GRAYSCALE_SELECTION_RGB.2, ); -pub const TEXT_BODY: Color = Color::Rgb(226, 232, 240); // #E2E8F0 -pub const TEXT_SECONDARY: Color = Color::Rgb(177, 190, 207); // #B1BECF -pub const TEXT_HINT: Color = Color::Rgb(135, 151, 171); // #8797AB -pub const TEXT_ACCENT: Color = DEEPSEEK_SKY; +pub const TEXT_BODY: Color = Color::Rgb( + WHALE_TEXT_BODY_RGB.0, + WHALE_TEXT_BODY_RGB.1, + WHALE_TEXT_BODY_RGB.2, +); +pub const TEXT_SECONDARY: Color = Color::Rgb( + WHALE_TEXT_MUTED_RGB.0, + WHALE_TEXT_MUTED_RGB.1, + WHALE_TEXT_MUTED_RGB.2, +); +pub const TEXT_HINT: Color = Color::Rgb( + WHALE_TEXT_HINT_RGB.0, + WHALE_TEXT_HINT_RGB.1, + WHALE_TEXT_HINT_RGB.2, +); +pub const TEXT_ACCENT: Color = Color::Rgb( + WHALE_ACCENT_SECONDARY_RGB.0, + WHALE_ACCENT_SECONDARY_RGB.1, + WHALE_ACCENT_SECONDARY_RGB.2, +); pub const SELECTION_TEXT: Color = Color::White; -pub const TEXT_SOFT: Color = Color::Rgb(217, 226, 238); // #D9E2EE -pub const TEXT_REASONING: Color = Color::Rgb(211, 170, 112); // #D3AA70 +pub const TEXT_SOFT: Color = Color::Rgb( + WHALE_TEXT_SOFT_RGB.0, + WHALE_TEXT_SOFT_RGB.1, + WHALE_TEXT_SOFT_RGB.2, +); +pub const TEXT_REASONING: Color = Color::Rgb( + WHALE_REASONING_TEXT_RGB.0, + WHALE_REASONING_TEXT_RGB.1, + WHALE_REASONING_TEXT_RGB.2, +); // Compatibility aliases for existing call sites. pub const TEXT_PRIMARY: Color = TEXT_BODY; @@ -200,51 +267,140 @@ pub const LIGHT_USER_BODY: Color = Color::Rgb(21, 128, 61); // #15803D green pub const BORDER_COLOR: Color = Color::Rgb(BORDER_COLOR_RGB.0, BORDER_COLOR_RGB.1, BORDER_COLOR_RGB.2); #[allow(dead_code)] -pub const ACCENT_PRIMARY: Color = DEEPSEEK_BLUE; // #3578E5 +pub const ACCENT_PRIMARY: Color = Color::Rgb( + WHALE_ACCENT_PRIMARY_RGB.0, + WHALE_ACCENT_PRIMARY_RGB.1, + WHALE_ACCENT_PRIMARY_RGB.2, +); #[allow(dead_code)] -pub const ACCENT_SECONDARY: Color = TEXT_ACCENT; // #6AAEF2 +pub const ACCENT_SECONDARY: Color = Color::Rgb( + WHALE_ACCENT_SECONDARY_RGB.0, + WHALE_ACCENT_SECONDARY_RGB.1, + WHALE_ACCENT_SECONDARY_RGB.2, +); #[allow(dead_code)] -pub const BACKGROUND_DARK: Color = Color::Rgb(13, 26, 48); // #0D1A30 +pub const BACKGROUND_DARK: Color = Color::Rgb(WHALE_BG_RGB.0, WHALE_BG_RGB.1, WHALE_BG_RGB.2); #[allow(dead_code)] -pub const STATUS_NEUTRAL: Color = Color::Rgb(160, 160, 160); // #A0A0A0 +pub const STATUS_NEUTRAL: Color = TEXT_MUTED; #[allow(dead_code)] -pub const SURFACE_PANEL: Color = Color::Rgb(21, 33, 52); // #152134 +pub const SURFACE_PANEL: Color = + Color::Rgb(WHALE_PANEL_RGB.0, WHALE_PANEL_RGB.1, WHALE_PANEL_RGB.2); #[allow(dead_code)] -pub const SURFACE_ELEVATED: Color = Color::Rgb(28, 42, 64); // #1C2A40 -pub const SURFACE_REASONING: Color = Color::Rgb(54, 44, 26); // #362C1A -pub const SURFACE_REASONING_TINT: Color = Color::Rgb(16, 24, 37); // #101825 +pub const SURFACE_ELEVATED: Color = Color::Rgb( + WHALE_ELEVATED_RGB.0, + WHALE_ELEVATED_RGB.1, + WHALE_ELEVATED_RGB.2, +); +pub const SURFACE_REASONING: Color = Color::Rgb( + WHALE_REASONING_SURFACE_RGB.0, + WHALE_REASONING_SURFACE_RGB.1, + WHALE_REASONING_SURFACE_RGB.2, +); +pub const SURFACE_REASONING_TINT: Color = Color::Rgb( + WHALE_REASONING_TINT_RGB.0, + WHALE_REASONING_TINT_RGB.1, + WHALE_REASONING_TINT_RGB.2, +); #[allow(dead_code)] -pub const SURFACE_REASONING_ACTIVE: Color = Color::Rgb(68, 53, 28); // #44351C +pub const SURFACE_REASONING_ACTIVE: Color = Color::Rgb(58, 46, 32); #[allow(dead_code)] -pub const SURFACE_TOOL: Color = Color::Rgb(24, 39, 60); // #18273C +pub const SURFACE_TOOL: Color = Color::Rgb( + WHALE_TOOL_SURFACE_RGB.0, + WHALE_TOOL_SURFACE_RGB.1, + WHALE_TOOL_SURFACE_RGB.2, +); #[allow(dead_code)] -pub const SURFACE_TOOL_ACTIVE: Color = Color::Rgb(29, 48, 73); // #1D3049 +pub const SURFACE_TOOL_ACTIVE: Color = Color::Rgb( + WHALE_TOOL_ACTIVE_RGB.0, + WHALE_TOOL_ACTIVE_RGB.1, + WHALE_TOOL_ACTIVE_RGB.2, +); #[allow(dead_code)] -pub const SURFACE_SUCCESS: Color = Color::Rgb(22, 56, 63); // #16383F +pub const SURFACE_SUCCESS: Color = Color::Rgb(18, 42, 37); // dark teal tint #[allow(dead_code)] -pub const SURFACE_ERROR: Color = Color::Rgb(63, 27, 36); // #3F1B24 -pub const DIFF_ADDED_BG: Color = Color::Rgb(18, 52, 38); // #123426 dark green tint -pub const DIFF_DELETED_BG: Color = Color::Rgb(52, 22, 28); // #34161C dark red tint -pub const DIFF_ADDED: Color = Color::Rgb(87, 199, 133); // #57C785 -pub const ACCENT_REASONING_LIVE: Color = Color::Rgb(224, 153, 72); // #E09948 -pub const ACCENT_TOOL_LIVE: Color = Color::Rgb(133, 184, 234); // #85B8EA -pub const ACCENT_TOOL_ISSUE: Color = Color::Rgb(192, 143, 153); // #C08F99 -pub const TEXT_TOOL_OUTPUT: Color = Color::Rgb(191, 205, 220); // #BFCEDC +pub const SURFACE_ERROR: Color = Color::Rgb( + WHALE_ERROR_SURFACE_RGB.0, + WHALE_ERROR_SURFACE_RGB.1, + WHALE_ERROR_SURFACE_RGB.2, +); +pub const DIFF_ADDED_BG: Color = Color::Rgb( + WHALE_DIFF_ADDED_BG_RGB.0, + WHALE_DIFF_ADDED_BG_RGB.1, + WHALE_DIFF_ADDED_BG_RGB.2, +); +pub const DIFF_DELETED_BG: Color = Color::Rgb( + WHALE_DIFF_DELETED_BG_RGB.0, + WHALE_DIFF_DELETED_BG_RGB.1, + WHALE_DIFF_DELETED_BG_RGB.2, +); +pub const DIFF_ADDED: Color = Color::Rgb( + WHALE_DIFF_ADDED_RGB.0, + WHALE_DIFF_ADDED_RGB.1, + WHALE_DIFF_ADDED_RGB.2, +); +pub const ACCENT_REASONING_LIVE: Color = Color::Rgb( + WHALE_REASONING_TEXT_RGB.0, + WHALE_REASONING_TEXT_RGB.1, + WHALE_REASONING_TEXT_RGB.2, +); +pub const ACCENT_TOOL_LIVE: Color = Color::Rgb( + WHALE_TOOL_LIVE_RGB.0, + WHALE_TOOL_LIVE_RGB.1, + WHALE_TOOL_LIVE_RGB.2, +); +pub const ACCENT_TOOL_ISSUE: Color = Color::Rgb( + WHALE_TOOL_ISSUE_RGB.0, + WHALE_TOOL_ISSUE_RGB.1, + WHALE_TOOL_ISSUE_RGB.2, +); +pub const TEXT_TOOL_OUTPUT: Color = Color::Rgb( + WHALE_TOOL_OUTPUT_RGB.0, + WHALE_TOOL_OUTPUT_RGB.1, + WHALE_TOOL_OUTPUT_RGB.2, +); // Legacy status colors - keep for backward compatibility -pub const STATUS_SUCCESS: Color = DEEPSEEK_SKY; -pub const STATUS_WARNING: Color = Color::Rgb(255, 170, 60); // Amber -pub const STATUS_ERROR: Color = DEEPSEEK_RED; +pub const STATUS_SUCCESS: Color = Color::Rgb( + WHALE_SUCCESS_RGB.0, + WHALE_SUCCESS_RGB.1, + WHALE_SUCCESS_RGB.2, +); +pub const STATUS_WARNING: Color = Color::Rgb( + WHALE_WARNING_RGB.0, + WHALE_WARNING_RGB.1, + WHALE_WARNING_RGB.2, +); +pub const STATUS_ERROR: Color = Color::Rgb(WHALE_ERROR_RGB.0, WHALE_ERROR_RGB.1, WHALE_ERROR_RGB.2); #[allow(dead_code)] -pub const STATUS_INFO: Color = DEEPSEEK_BLUE; +pub const STATUS_INFO: Color = Color::Rgb(WHALE_INFO_RGB.0, WHALE_INFO_RGB.1, WHALE_INFO_RGB.2); // Mode-specific accent colors for mode badges -pub const MODE_AGENT: Color = Color::Rgb(80, 150, 255); // Bright blue -pub const MODE_YOLO: Color = Color::Rgb(255, 100, 100); // Warning red -pub const MODE_PLAN: Color = Color::Rgb(255, 170, 60); // Orange -pub const MODE_GOAL: Color = Color::Rgb(100, 220, 160); // Mint green +pub const MODE_AGENT: Color = Color::Rgb( + WHALE_MODE_AGENT_RGB.0, + WHALE_MODE_AGENT_RGB.1, + WHALE_MODE_AGENT_RGB.2, +); +pub const MODE_YOLO: Color = Color::Rgb( + WHALE_MODE_YOLO_RGB.0, + WHALE_MODE_YOLO_RGB.1, + WHALE_MODE_YOLO_RGB.2, +); +pub const MODE_PLAN: Color = Color::Rgb( + WHALE_MODE_PLAN_RGB.0, + WHALE_MODE_PLAN_RGB.1, + WHALE_MODE_PLAN_RGB.2, +); +pub const MODE_GOAL: Color = Color::Rgb( + WHALE_MODE_GOAL_RGB.0, + WHALE_MODE_GOAL_RGB.1, + WHALE_MODE_GOAL_RGB.2, +); -pub const SELECTION_BG: Color = Color::Rgb(26, 44, 74); +pub const SELECTION_BG: Color = Color::Rgb( + WHALE_SELECTION_RGB.0, + WHALE_SELECTION_RGB.1, + WHALE_SELECTION_RGB.2, +); #[allow(dead_code)] pub const COMPOSER_BG: Color = DEEPSEEK_SLATE; @@ -322,6 +478,7 @@ fn palette_mode_from_apple_interface_style(value: &str) -> PaletteMode { pub struct UiTheme { pub name: &'static str, pub mode: PaletteMode, + // Surface hierarchy pub surface_bg: Color, pub panel_bg: Color, pub elevated_bg: Color, @@ -329,22 +486,45 @@ pub struct UiTheme { pub selection_bg: Color, pub header_bg: Color, pub footer_bg: Color, - /// Statusline mode colors (agent/yolo/plan) - pub mode_agent: Color, - pub mode_yolo: Color, - pub mode_plan: Color, - pub mode_goal: Color, - /// Statusline status colors - pub status_ready: Color, - pub status_working: Color, - pub status_warning: Color, - /// Statusline text colors + /// Text hierarchy pub text_dim: Color, pub text_hint: Color, pub text_muted: Color, pub text_body: Color, pub text_soft: Color, pub border: Color, + // Accent roles + pub accent_primary: Color, + pub accent_secondary: Color, + pub accent_action: Color, + // Error / destructive + pub error_fg: Color, + pub error_hover: Color, + pub error_surface: Color, + pub error_border: Color, + pub error_text: Color, + // Status roles (warning / success / info) + pub warning: Color, + pub success: Color, + pub info: Color, + // Mode badge colors (agent/yolo/plan/goal) + pub mode_agent: Color, + pub mode_yolo: Color, + pub mode_plan: Color, + pub mode_goal: Color, + // Footer statusline colors + pub status_ready: Color, + pub status_working: Color, + pub status_warning: Color, + // Diff colors + pub diff_added_fg: Color, + pub diff_deleted_fg: Color, + pub diff_added_bg: Color, + pub diff_deleted_bg: Color, + // Tool cell colors + pub tool_running: Color, + pub tool_success: Color, + pub tool_failed: Color, } pub const UI_THEME: UiTheme = UiTheme { @@ -357,6 +537,59 @@ pub const UI_THEME: UiTheme = UiTheme { selection_bg: SELECTION_BG, header_bg: DEEPSEEK_INK, footer_bg: DEEPSEEK_INK, + text_dim: TEXT_DIM, + text_hint: TEXT_HINT, + text_muted: TEXT_MUTED, + text_body: TEXT_BODY, + text_soft: TEXT_SOFT, + border: BORDER_COLOR, + accent_primary: Color::Rgb( + WHALE_ACCENT_PRIMARY_RGB.0, + WHALE_ACCENT_PRIMARY_RGB.1, + WHALE_ACCENT_PRIMARY_RGB.2, + ), + accent_secondary: Color::Rgb( + WHALE_ACCENT_SECONDARY_RGB.0, + WHALE_ACCENT_SECONDARY_RGB.1, + WHALE_ACCENT_SECONDARY_RGB.2, + ), + accent_action: Color::Rgb( + WHALE_ACCENT_ACTION_RGB.0, + WHALE_ACCENT_ACTION_RGB.1, + WHALE_ACCENT_ACTION_RGB.2, + ), + error_fg: Color::Rgb(WHALE_ERROR_RGB.0, WHALE_ERROR_RGB.1, WHALE_ERROR_RGB.2), + error_hover: Color::Rgb( + WHALE_ERROR_HOVER_RGB.0, + WHALE_ERROR_HOVER_RGB.1, + WHALE_ERROR_HOVER_RGB.2, + ), + error_surface: Color::Rgb( + WHALE_ERROR_SURFACE_RGB.0, + WHALE_ERROR_SURFACE_RGB.1, + WHALE_ERROR_SURFACE_RGB.2, + ), + error_border: Color::Rgb( + WHALE_ERROR_BORDER_RGB.0, + WHALE_ERROR_BORDER_RGB.1, + WHALE_ERROR_BORDER_RGB.2, + ), + error_text: Color::Rgb( + WHALE_ERROR_TEXT_RGB.0, + WHALE_ERROR_TEXT_RGB.1, + WHALE_ERROR_TEXT_RGB.2, + ), + warning: Color::Rgb( + WHALE_WARNING_RGB.0, + WHALE_WARNING_RGB.1, + WHALE_WARNING_RGB.2, + ), + success: Color::Rgb( + WHALE_SUCCESS_RGB.0, + WHALE_SUCCESS_RGB.1, + WHALE_SUCCESS_RGB.2, + ), + info: Color::Rgb(WHALE_INFO_RGB.0, WHALE_INFO_RGB.1, WHALE_INFO_RGB.2), mode_agent: MODE_AGENT, mode_yolo: MODE_YOLO, mode_plan: MODE_PLAN, @@ -364,12 +597,13 @@ pub const UI_THEME: UiTheme = UiTheme { status_ready: TEXT_MUTED, status_working: DEEPSEEK_SKY, status_warning: STATUS_WARNING, - text_dim: TEXT_DIM, - text_hint: TEXT_HINT, - text_muted: TEXT_MUTED, - text_body: TEXT_BODY, - text_soft: TEXT_SOFT, - border: BORDER_COLOR, + diff_added_fg: DIFF_ADDED, + diff_deleted_fg: Color::Rgb(WHALE_ERROR_RGB.0, WHALE_ERROR_RGB.1, WHALE_ERROR_RGB.2), + diff_added_bg: DIFF_ADDED_BG, + diff_deleted_bg: DIFF_DELETED_BG, + tool_running: ACCENT_TOOL_LIVE, + tool_success: TEXT_DIM, + tool_failed: ACCENT_TOOL_ISSUE, }; pub const LIGHT_UI_THEME: UiTheme = UiTheme { @@ -382,19 +616,37 @@ pub const LIGHT_UI_THEME: UiTheme = UiTheme { selection_bg: LIGHT_SELECTION_BG, header_bg: LIGHT_SURFACE, footer_bg: LIGHT_SURFACE, - mode_agent: DEEPSEEK_BLUE, - mode_yolo: DEEPSEEK_RED, - mode_plan: Color::Rgb(180, 83, 9), - mode_goal: Color::Rgb(80, 180, 130), // mint green - status_ready: LIGHT_TEXT_MUTED, - status_working: DEEPSEEK_BLUE, - status_warning: Color::Rgb(180, 83, 9), text_dim: LIGHT_TEXT_HINT, text_hint: LIGHT_TEXT_HINT, text_muted: LIGHT_TEXT_MUTED, text_body: LIGHT_TEXT_BODY, text_soft: LIGHT_TEXT_SOFT, border: LIGHT_BORDER, + accent_primary: Color::Rgb(53, 120, 229), // blue + accent_secondary: Color::Rgb(79, 180, 160), // teal + accent_action: Color::Rgb(220, 90, 60), // warm coral + error_fg: Color::Rgb(200, 40, 60), // red + error_hover: Color::Rgb(220, 70, 85), + error_surface: Color::Rgb(254, 229, 229), + error_border: Color::Rgb(240, 120, 130), + error_text: Color::Rgb(120, 20, 30), + warning: Color::Rgb(180, 83, 9), // amber + success: Color::Rgb(21, 128, 61), // green + info: Color::Rgb(53, 120, 229), // blue + mode_agent: Color::Rgb(53, 120, 229), // blue + mode_yolo: Color::Rgb(200, 40, 60), // red + mode_plan: Color::Rgb(180, 83, 9), // amber + mode_goal: Color::Rgb(80, 180, 130), // mint green + status_ready: LIGHT_TEXT_MUTED, + status_working: Color::Rgb(53, 120, 229), // blue + status_warning: Color::Rgb(180, 83, 9), // amber + diff_added_fg: Color::Rgb(22, 101, 52), // green + diff_deleted_fg: Color::Rgb(200, 40, 60), // red + diff_added_bg: Color::Rgb(223, 247, 231), // light green + diff_deleted_bg: Color::Rgb(254, 229, 229), // light red + tool_running: Color::Rgb(53, 120, 229), // blue + tool_success: LIGHT_TEXT_HINT, + tool_failed: Color::Rgb(200, 40, 60), // red }; pub const GRAYSCALE_UI_THEME: UiTheme = UiTheme { @@ -407,19 +659,37 @@ pub const GRAYSCALE_UI_THEME: UiTheme = UiTheme { selection_bg: GRAYSCALE_SELECTION_BG, header_bg: GRAYSCALE_SURFACE, footer_bg: GRAYSCALE_SURFACE, - mode_agent: GRAYSCALE_TEXT_SOFT, - mode_yolo: GRAYSCALE_TEXT_BODY, - mode_plan: GRAYSCALE_TEXT_MUTED, - mode_goal: GRAYSCALE_TEXT_SOFT, - status_ready: GRAYSCALE_TEXT_MUTED, - status_working: GRAYSCALE_TEXT_SOFT, - status_warning: GRAYSCALE_TEXT_BODY, text_dim: GRAYSCALE_TEXT_HINT, text_hint: GRAYSCALE_TEXT_HINT, text_muted: GRAYSCALE_TEXT_MUTED, text_body: GRAYSCALE_TEXT_BODY, text_soft: GRAYSCALE_TEXT_SOFT, border: GRAYSCALE_BORDER, + accent_primary: GRAYSCALE_TEXT_SOFT, + accent_secondary: GRAYSCALE_TEXT_MUTED, + accent_action: Color::Rgb(210, 210, 210), + error_fg: GRAYSCALE_TEXT_BODY, + error_hover: GRAYSCALE_TEXT_SOFT, + error_surface: GRAYSCALE_ERROR, + error_border: GRAYSCALE_BORDER, + error_text: GRAYSCALE_TEXT_SOFT, + warning: GRAYSCALE_TEXT_MUTED, + success: GRAYSCALE_TEXT_SOFT, + info: GRAYSCALE_TEXT_MUTED, + mode_agent: Color::Rgb(200, 200, 200), + mode_yolo: GRAYSCALE_TEXT_BODY, + mode_plan: GRAYSCALE_TEXT_MUTED, + mode_goal: GRAYSCALE_TEXT_SOFT, + status_ready: GRAYSCALE_TEXT_MUTED, + status_working: GRAYSCALE_TEXT_SOFT, + status_warning: GRAYSCALE_TEXT_BODY, + diff_added_fg: GRAYSCALE_TEXT_SOFT, + diff_deleted_fg: GRAYSCALE_TEXT_BODY, + diff_added_bg: GRAYSCALE_SUCCESS, + diff_deleted_bg: GRAYSCALE_ERROR, + tool_running: GRAYSCALE_TEXT_SOFT, + tool_success: GRAYSCALE_TEXT_HINT, + tool_failed: GRAYSCALE_TEXT_BODY, }; pub const CATPPUCCIN_MOCHA_UI_THEME: UiTheme = UiTheme { @@ -432,19 +702,37 @@ pub const CATPPUCCIN_MOCHA_UI_THEME: UiTheme = UiTheme { selection_bg: Color::Rgb(0x45, 0x47, 0x5a), // surface1 header_bg: Color::Rgb(0x11, 0x11, 0x1b), // crust footer_bg: Color::Rgb(0x11, 0x11, 0x1b), - mode_agent: Color::Rgb(0x89, 0xb4, 0xfa), // blue - mode_yolo: Color::Rgb(0xf3, 0x8b, 0xa8), // red - mode_plan: Color::Rgb(0xfa, 0xb3, 0x87), // peach - mode_goal: Color::Rgb(0xa6, 0xe3, 0xa1), // green - status_ready: Color::Rgb(0x7f, 0x84, 0x9c), // overlay1 - status_working: Color::Rgb(0x74, 0xc7, 0xec), // sapphire - status_warning: Color::Rgb(0xf9, 0xe2, 0xaf), // yellow - text_dim: Color::Rgb(0x6c, 0x70, 0x86), // overlay0 - text_hint: Color::Rgb(0x7f, 0x84, 0x9c), // overlay1 - text_muted: Color::Rgb(0xa6, 0xad, 0xc8), // subtext0 - text_body: Color::Rgb(0xcd, 0xd6, 0xf4), // text - text_soft: Color::Rgb(0xba, 0xc2, 0xde), // subtext1 - border: Color::Rgb(0x45, 0x47, 0x5a), // surface1 + text_dim: Color::Rgb(0x6c, 0x70, 0x86), // overlay0 + text_hint: Color::Rgb(0x7f, 0x84, 0x9c), // overlay1 + text_muted: Color::Rgb(0xa6, 0xad, 0xc8), // subtext0 + text_body: Color::Rgb(0xcd, 0xd6, 0xf4), // text + text_soft: Color::Rgb(0xba, 0xc2, 0xde), // subtext1 + border: Color::Rgb(0x45, 0x47, 0x5a), // surface1 + accent_primary: Color::Rgb(0x89, 0xb4, 0xfa), // blue + accent_secondary: Color::Rgb(0x74, 0xc7, 0xec), // sapphire + accent_action: Color::Rgb(0xfa, 0xb3, 0x87), // peach + error_fg: Color::Rgb(0xf3, 0x8b, 0xa8), // red + error_hover: Color::Rgb(0xf5, 0xa2, 0xbc), + error_surface: Color::Rgb(0x3a, 0x1f, 0x2a), + error_border: Color::Rgb(0xf3, 0x8b, 0xa8), + error_text: Color::Rgb(0xf5, 0xc2, 0xd0), + warning: Color::Rgb(0xf9, 0xe2, 0xaf), // yellow + success: Color::Rgb(0xa6, 0xe3, 0xa1), // green + info: Color::Rgb(0x89, 0xd9, 0xeb), // sky + mode_agent: Color::Rgb(0x89, 0xb4, 0xfa), // blue + mode_yolo: Color::Rgb(0xf3, 0x8b, 0xa8), // red + mode_plan: Color::Rgb(0xfa, 0xb3, 0x87), // peach + mode_goal: Color::Rgb(0xa6, 0xe3, 0xa1), // green + status_ready: Color::Rgb(0x7f, 0x84, 0x9c), // overlay1 + status_working: Color::Rgb(0x74, 0xc7, 0xec), // sapphire + status_warning: Color::Rgb(0xf9, 0xe2, 0xaf), // yellow + diff_added_fg: Color::Rgb(0xa6, 0xe3, 0xa1), // green + diff_deleted_fg: Color::Rgb(0xf3, 0x8b, 0xa8), // red + diff_added_bg: Color::Rgb(0x1f, 0x33, 0x29), + diff_deleted_bg: Color::Rgb(0x3a, 0x1f, 0x2a), + tool_running: Color::Rgb(0x74, 0xc7, 0xec), // sapphire + tool_success: Color::Rgb(0x7f, 0x84, 0x9c), // overlay1 + tool_failed: Color::Rgb(0xf3, 0x8b, 0xa8), // red }; pub const TOKYO_NIGHT_UI_THEME: UiTheme = UiTheme { @@ -457,19 +745,37 @@ pub const TOKYO_NIGHT_UI_THEME: UiTheme = UiTheme { selection_bg: Color::Rgb(0x28, 0x34, 0x57), // visual selection header_bg: Color::Rgb(0x16, 0x16, 0x1e), footer_bg: Color::Rgb(0x16, 0x16, 0x1e), - mode_agent: Color::Rgb(0x7a, 0xa2, 0xf7), // blue - mode_yolo: Color::Rgb(0xf7, 0x76, 0x8e), // red - mode_plan: Color::Rgb(0xff, 0x9e, 0x64), // orange - mode_goal: Color::Rgb(0x9e, 0xce, 0x6a), // green - status_ready: Color::Rgb(0x56, 0x5f, 0x89), // comment - status_working: Color::Rgb(0x7d, 0xcf, 0xff), // cyan - status_warning: Color::Rgb(0xe0, 0xaf, 0x68), // yellow - text_dim: Color::Rgb(0x56, 0x5f, 0x89), // comment - text_hint: Color::Rgb(0x73, 0x7a, 0xa2), // dark5 - text_muted: Color::Rgb(0xa9, 0xb1, 0xd6), // fg_dark - text_body: Color::Rgb(0xc0, 0xca, 0xf5), // fg + text_dim: Color::Rgb(0x56, 0x5f, 0x89), // comment + text_hint: Color::Rgb(0x73, 0x7a, 0xa2), // dark5 + text_muted: Color::Rgb(0xa9, 0xb1, 0xd6), // fg_dark + text_body: Color::Rgb(0xc0, 0xca, 0xf5), // fg text_soft: Color::Rgb(0xbb, 0xc2, 0xe0), border: Color::Rgb(0x41, 0x48, 0x68), // terminal_black + accent_primary: Color::Rgb(0x7a, 0xa2, 0xf7), // blue + accent_secondary: Color::Rgb(0x7d, 0xcf, 0xff), // cyan + accent_action: Color::Rgb(0xff, 0x9e, 0x64), // orange + error_fg: Color::Rgb(0xf7, 0x76, 0x8e), // red + error_hover: Color::Rgb(0xf9, 0x92, 0xa4), + error_surface: Color::Rgb(0x33, 0x1c, 0x24), + error_border: Color::Rgb(0xf7, 0x76, 0x8e), + error_text: Color::Rgb(0xfa, 0xcc, 0xd4), + warning: Color::Rgb(0xe0, 0xaf, 0x68), // yellow + success: Color::Rgb(0x9e, 0xce, 0x6a), // green + info: Color::Rgb(0x7d, 0xcf, 0xff), // cyan + mode_agent: Color::Rgb(0x7a, 0xa2, 0xf7), // blue + mode_yolo: Color::Rgb(0xf7, 0x76, 0x8e), // red + mode_plan: Color::Rgb(0xff, 0x9e, 0x64), // orange + mode_goal: Color::Rgb(0x9e, 0xce, 0x6a), // green + status_ready: Color::Rgb(0x56, 0x5f, 0x89), // comment + status_working: Color::Rgb(0x7d, 0xcf, 0xff), // cyan + status_warning: Color::Rgb(0xe0, 0xaf, 0x68), // yellow + diff_added_fg: Color::Rgb(0x9e, 0xce, 0x6a), // green + diff_deleted_fg: Color::Rgb(0xf7, 0x76, 0x8e), // red + diff_added_bg: Color::Rgb(0x1b, 0x2b, 0x1f), + diff_deleted_bg: Color::Rgb(0x33, 0x1c, 0x24), + tool_running: Color::Rgb(0x7d, 0xcf, 0xff), // cyan + tool_success: Color::Rgb(0x56, 0x5f, 0x89), // comment + tool_failed: Color::Rgb(0xf7, 0x76, 0x8e), // red }; pub const DRACULA_UI_THEME: UiTheme = UiTheme { @@ -482,19 +788,37 @@ pub const DRACULA_UI_THEME: UiTheme = UiTheme { selection_bg: Color::Rgb(0x44, 0x47, 0x5a), // current line header_bg: Color::Rgb(0x21, 0x22, 0x2c), footer_bg: Color::Rgb(0x21, 0x22, 0x2c), - mode_agent: Color::Rgb(0xbd, 0x93, 0xf9), // purple - mode_yolo: Color::Rgb(0xff, 0x55, 0x55), // red - mode_plan: Color::Rgb(0xff, 0xb8, 0x6c), // orange - mode_goal: Color::Rgb(0x50, 0xfa, 0x7b), // green - status_ready: Color::Rgb(0x62, 0x72, 0xa4), // comment - status_working: Color::Rgb(0x8b, 0xe9, 0xfd), // cyan - status_warning: Color::Rgb(0xf1, 0xfa, 0x8c), // yellow - text_dim: Color::Rgb(0x62, 0x72, 0xa4), + text_dim: Color::Rgb(0x62, 0x72, 0xa4), // comment text_hint: Color::Rgb(0x8a, 0x8e, 0xaa), text_muted: Color::Rgb(0xc0, 0xc4, 0xd6), text_body: Color::Rgb(0xf8, 0xf8, 0xf2), // foreground text_soft: Color::Rgb(0xe2, 0xe2, 0xdc), border: Color::Rgb(0x44, 0x47, 0x5a), + accent_primary: Color::Rgb(0xbd, 0x93, 0xf9), // purple + accent_secondary: Color::Rgb(0x8b, 0xe9, 0xfd), // cyan + accent_action: Color::Rgb(0xff, 0xb8, 0x6c), // orange + error_fg: Color::Rgb(0xff, 0x55, 0x55), // red + error_hover: Color::Rgb(0xff, 0x7c, 0x7c), + error_surface: Color::Rgb(0x3a, 0x1f, 0x22), + error_border: Color::Rgb(0xff, 0x55, 0x55), + error_text: Color::Rgb(0xff, 0xbb, 0xbb), + warning: Color::Rgb(0xf1, 0xfa, 0x8c), // yellow + success: Color::Rgb(0x50, 0xfa, 0x7b), // green + info: Color::Rgb(0x8b, 0xe9, 0xfd), // cyan + mode_agent: Color::Rgb(0xbd, 0x93, 0xf9), // purple + mode_yolo: Color::Rgb(0xff, 0x55, 0x55), // red + mode_plan: Color::Rgb(0xff, 0xb8, 0x6c), // orange + mode_goal: Color::Rgb(0x50, 0xfa, 0x7b), // green + status_ready: Color::Rgb(0x62, 0x72, 0xa4), // comment + status_working: Color::Rgb(0x8b, 0xe9, 0xfd), // cyan + status_warning: Color::Rgb(0xf1, 0xfa, 0x8c), // yellow + diff_added_fg: Color::Rgb(0x50, 0xfa, 0x7b), // green + diff_deleted_fg: Color::Rgb(0xff, 0x55, 0x55), // red + diff_added_bg: Color::Rgb(0x21, 0x3a, 0x2a), + diff_deleted_bg: Color::Rgb(0x3a, 0x1f, 0x22), + tool_running: Color::Rgb(0x8b, 0xe9, 0xfd), // cyan + tool_success: Color::Rgb(0x62, 0x72, 0xa4), // comment + tool_failed: Color::Rgb(0xff, 0x55, 0x55), // red }; pub const GRUVBOX_DARK_UI_THEME: UiTheme = UiTheme { @@ -507,19 +831,37 @@ pub const GRUVBOX_DARK_UI_THEME: UiTheme = UiTheme { selection_bg: Color::Rgb(0x66, 0x5c, 0x54), // bg3 header_bg: Color::Rgb(0x1d, 0x20, 0x21), // bg0_h footer_bg: Color::Rgb(0x1d, 0x20, 0x21), - mode_agent: Color::Rgb(0x83, 0xa5, 0x98), // blue - mode_yolo: Color::Rgb(0xfb, 0x49, 0x34), // red - mode_plan: Color::Rgb(0xfe, 0x80, 0x19), // orange - mode_goal: Color::Rgb(0x8e, 0xc0, 0x7c), // green - status_ready: Color::Rgb(0x92, 0x83, 0x74), // gray - status_working: Color::Rgb(0x8e, 0xc0, 0x7c), // aqua - status_warning: Color::Rgb(0xfa, 0xbd, 0x2f), // yellow - text_dim: Color::Rgb(0x92, 0x83, 0x74), // gray - text_hint: Color::Rgb(0xa8, 0x99, 0x84), // fg4 - text_muted: Color::Rgb(0xbd, 0xae, 0x93), // fg3 - text_body: Color::Rgb(0xeb, 0xdb, 0xb2), // fg1 - text_soft: Color::Rgb(0xd5, 0xc4, 0xa1), // fg2 - border: Color::Rgb(0x66, 0x5c, 0x54), // bg3 + text_dim: Color::Rgb(0x92, 0x83, 0x74), // gray + text_hint: Color::Rgb(0xa8, 0x99, 0x84), // fg4 + text_muted: Color::Rgb(0xbd, 0xae, 0x93), // fg3 + text_body: Color::Rgb(0xeb, 0xdb, 0xb2), // fg1 + text_soft: Color::Rgb(0xd5, 0xc4, 0xa1), // fg2 + border: Color::Rgb(0x66, 0x5c, 0x54), // bg3 + accent_primary: Color::Rgb(0x83, 0xa5, 0x98), // blue + accent_secondary: Color::Rgb(0x8e, 0xc0, 0x7c), // aqua/green + accent_action: Color::Rgb(0xfe, 0x80, 0x19), // orange + error_fg: Color::Rgb(0xfb, 0x49, 0x34), // red + error_hover: Color::Rgb(0xfc, 0x7c, 0x6b), + error_surface: Color::Rgb(0x35, 0x1c, 0x18), + error_border: Color::Rgb(0xfb, 0x49, 0x34), + error_text: Color::Rgb(0xfc, 0xc4, 0xb8), + warning: Color::Rgb(0xfa, 0xbd, 0x2f), // yellow + success: Color::Rgb(0x8e, 0xc0, 0x7c), // green + info: Color::Rgb(0x83, 0xa5, 0x98), // blue + mode_agent: Color::Rgb(0x83, 0xa5, 0x98), // blue + mode_yolo: Color::Rgb(0xfb, 0x49, 0x34), // red + mode_plan: Color::Rgb(0xfe, 0x80, 0x19), // orange + mode_goal: Color::Rgb(0x8e, 0xc0, 0x7c), // green + status_ready: Color::Rgb(0x92, 0x83, 0x74), // gray + status_working: Color::Rgb(0x8e, 0xc0, 0x7c), // aqua + status_warning: Color::Rgb(0xfa, 0xbd, 0x2f), // yellow + diff_added_fg: Color::Rgb(0x8e, 0xc0, 0x7c), // green + diff_deleted_fg: Color::Rgb(0xfb, 0x49, 0x34), // red + diff_added_bg: Color::Rgb(0x29, 0x32, 0x16), + diff_deleted_bg: Color::Rgb(0x35, 0x1c, 0x18), + tool_running: Color::Rgb(0x8e, 0xc0, 0x7c), // aqua + tool_success: Color::Rgb(0x92, 0x83, 0x74), // gray + tool_failed: Color::Rgb(0xfb, 0x49, 0x34), // red }; /// Stable identifiers for the named themes the user can select. `System` @@ -592,7 +934,7 @@ impl ThemeId { pub const fn tagline(self) -> &'static str { match self { Self::System => "Follow terminal background (COLORFGBG / macOS appearance)", - Self::Whale => "Default DeepSeek dark blue", + Self::Whale => "Whale dark — deep navy & gold", Self::WhaleLight => "DeepSeek light, paper-ish", Self::Grayscale => "Color-minimal high contrast", Self::CatppuccinMocha => "Soft pastels on warm dark", @@ -809,54 +1151,30 @@ fn adapt_bg_for_light_palette(color: Color) -> Color { // no-op — the existing dark/light pipeline handles those. /// Per-preset green accent used for things that semantically *should* stay -/// green even after theming (diff "+" lines, user-input body). Mapping these -/// to `ui.status_working` would lose the green/cyan distinction the UI -/// relies on, so we keep a small dedicated table. +/// green even after theming (diff "+" lines, user-input body). Now delegates +/// to the active UiTheme's diff_added_fg. #[must_use] -const fn theme_green(theme: ThemeId) -> Color { - match theme { - ThemeId::CatppuccinMocha => Color::Rgb(0xa6, 0xe3, 0xa1), - ThemeId::TokyoNight => Color::Rgb(0x9e, 0xce, 0x6a), - ThemeId::Dracula => Color::Rgb(0x50, 0xfa, 0x7b), - ThemeId::GruvboxDark => Color::Rgb(0xb8, 0xbb, 0x26), - _ => USER_BODY, - } +const fn theme_green(ui: &UiTheme) -> Color { + ui.diff_added_fg } /// Per-preset red accent, used for diff "−" line foreground when present. #[must_use] -const fn theme_red(theme: ThemeId) -> Color { - match theme { - ThemeId::CatppuccinMocha => Color::Rgb(0xf3, 0x8b, 0xa8), - ThemeId::TokyoNight => Color::Rgb(0xf7, 0x76, 0x8e), - ThemeId::Dracula => Color::Rgb(0xff, 0x55, 0x55), - ThemeId::GruvboxDark => Color::Rgb(0xfb, 0x49, 0x34), - _ => DEEPSEEK_RED, - } +#[allow(dead_code)] +const fn theme_red(ui: &UiTheme) -> Color { + ui.diff_deleted_fg } /// Per-preset dark-green diff-added background tint. #[must_use] -const fn theme_diff_added_bg(theme: ThemeId) -> Color { - match theme { - ThemeId::CatppuccinMocha => Color::Rgb(0x1f, 0x33, 0x29), - ThemeId::TokyoNight => Color::Rgb(0x1b, 0x2b, 0x1f), - ThemeId::Dracula => Color::Rgb(0x21, 0x3a, 0x2a), - ThemeId::GruvboxDark => Color::Rgb(0x29, 0x32, 0x16), - _ => DIFF_ADDED_BG, - } +const fn theme_diff_added_bg(ui: &UiTheme) -> Color { + ui.diff_added_bg } /// Per-preset dark-red diff-deleted background tint. #[must_use] -const fn theme_diff_deleted_bg(theme: ThemeId) -> Color { - match theme { - ThemeId::CatppuccinMocha => Color::Rgb(0x3a, 0x1f, 0x2a), - ThemeId::TokyoNight => Color::Rgb(0x33, 0x1c, 0x24), - ThemeId::Dracula => Color::Rgb(0x3a, 0x1f, 0x22), - ThemeId::GruvboxDark => Color::Rgb(0x35, 0x1c, 0x18), - _ => DIFF_DELETED_BG, - } +const fn theme_diff_deleted_bg(ui: &UiTheme) -> Color { + ui.diff_deleted_bg } /// Returns `true` if the preset participates in the cell-level remap. The @@ -905,13 +1223,12 @@ pub fn adapt_fg_for_theme(color: Color, theme: ThemeId, ui: &UiTheme) -> Color { } else if color == ACCENT_TOOL_ISSUE { ui.mode_yolo } else if color == STATUS_WARNING { - ui.status_warning - } else if color == DEEPSEEK_RED { - theme_red(theme) + ui.warning + } else if color == STATUS_ERROR || color == DEEPSEEK_RED { + ui.error_fg } else if color == DIFF_ADDED || color == USER_BODY { - theme_green(theme) + theme_green(ui) } else if color == DEEPSEEK_BLUE { - // The default mode_agent accent — keep it in the preset's blue family. ui.mode_agent } else { color @@ -939,19 +1256,18 @@ pub fn adapt_bg_for_theme(color: Color, theme: ThemeId, ui: &UiTheme) -> Color { } else if color == SURFACE_REASONING || color == SURFACE_REASONING_TINT || color == SURFACE_REASONING_ACTIVE - || color == SURFACE_SUCCESS - || color == SURFACE_ERROR { - // Reasoning/success/error backgrounds are subtle tints that don't have - // a dedicated theme slot. Collapse them onto the panel surface so they - // read as recessed rather than a stray default-blue tint. ui.panel_bg + } else if color == SURFACE_SUCCESS { + ui.diff_added_bg + } else if color == SURFACE_ERROR { + ui.error_surface } else if color == SELECTION_BG { ui.selection_bg } else if color == DIFF_ADDED_BG { - theme_diff_added_bg(theme) + theme_diff_added_bg(ui) } else if color == DIFF_DELETED_BG { - theme_diff_deleted_bg(theme) + theme_diff_deleted_bg(ui) } else { color } @@ -1208,10 +1524,9 @@ pub fn blend(fg: Color, bg: Color, alpha: f32) -> Color { } } -/// Return the reasoning surface color tinted at 12% over the app background. -/// This is the headline reasoning treatment in v0.6.6; a 12% blend keeps the -/// warm bias subtle without competing with body text. Returns `None` when the -/// terminal can't render the bg faithfully. +/// Return the dedicated reasoning surface tint for terminals that can render +/// background colors faithfully. ANSI-16 terminals disable the tint because +/// the nearest named background is too coarse for this subtle treatment. #[must_use] pub fn reasoning_surface_tint(depth: ColorDepth) -> Option { match depth { @@ -1363,7 +1678,8 @@ mod tests { GRAYSCALE_UI_THEME, LIGHT_BORDER, LIGHT_ELEVATED, LIGHT_PANEL, LIGHT_REASONING, LIGHT_SURFACE, LIGHT_TEXT_BODY, LIGHT_TEXT_HINT, LIGHT_UI_THEME, PaletteMode, SURFACE_REASONING, SURFACE_REASONING_TINT, TEXT_BODY, TEXT_HINT, TEXT_REASONING, - TEXT_TOOL_OUTPUT, UI_THEME, adapt_bg, adapt_bg_for_palette_mode, adapt_color, + TEXT_TOOL_OUTPUT, UI_THEME, WHALE_REASONING_TEXT_RGB, WHALE_REASONING_TINT_RGB, + WHALE_TEXT_BODY_RGB, adapt_bg, adapt_bg_for_palette_mode, adapt_color, adapt_fg_for_palette_mode, blend, luma, nearest_ansi16, normalize_hex_rgb_color, normalize_theme_name, parse_hex_rgb_color, pulse_brightness, reasoning_surface_tint, rgb_to_ansi256, theme_label_for_mode, ui_theme_from_settings, @@ -1468,9 +1784,30 @@ mod tests { #[test] fn dark_palette_uses_soft_body_text_and_warm_reasoning() { - assert_eq!(TEXT_BODY, Color::Rgb(226, 232, 240)); - assert_eq!(TEXT_REASONING, Color::Rgb(211, 170, 112)); - assert_eq!(ACCENT_REASONING_LIVE, Color::Rgb(224, 153, 72)); + assert_eq!( + TEXT_BODY, + Color::Rgb( + WHALE_TEXT_BODY_RGB.0, + WHALE_TEXT_BODY_RGB.1, + WHALE_TEXT_BODY_RGB.2 + ) + ); + assert_eq!( + TEXT_REASONING, + Color::Rgb( + WHALE_REASONING_TEXT_RGB.0, + WHALE_REASONING_TEXT_RGB.1, + WHALE_REASONING_TEXT_RGB.2 + ) + ); + assert_eq!( + ACCENT_REASONING_LIVE, + Color::Rgb( + WHALE_REASONING_TEXT_RGB.0, + WHALE_REASONING_TEXT_RGB.1, + WHALE_REASONING_TEXT_RGB.2 + ) + ); assert_ne!(TEXT_REASONING, TEXT_TOOL_OUTPUT); assert_ne!(TEXT_BODY, Color::White); } @@ -1604,8 +1941,12 @@ mod tests { adapt_color(DEEPSEEK_SKY, ColorDepth::Ansi16), Color::LightBlue ); - // Red: red-dominant, mid lum → Red (not the bright variant). - assert_eq!(adapt_color(DEEPSEEK_RED, ColorDepth::Ansi16), Color::Red); + // Rose Red is intentionally bright enough to use the terminal's + // bright red slot. + assert_eq!( + adapt_color(DEEPSEEK_RED, ColorDepth::Ansi16), + Color::LightRed + ); } #[test] @@ -1633,8 +1974,12 @@ mod tests { #[test] fn light_palette_maps_reasoning_tint_to_light_surface() { assert_eq!( - blend(SURFACE_REASONING, DEEPSEEK_INK, 0.12), - SURFACE_REASONING_TINT + SURFACE_REASONING_TINT, + Color::Rgb( + WHALE_REASONING_TINT_RGB.0, + WHALE_REASONING_TINT_RGB.1, + WHALE_REASONING_TINT_RGB.2 + ) ); assert_eq!( adapt_bg_for_palette_mode(SURFACE_REASONING_TINT, PaletteMode::Light), @@ -1693,14 +2038,13 @@ mod tests { #[test] fn nearest_ansi16_routes_known_brand_colors() { - // Blue-dominant brand colors should stay blue rather than collapsing - // to the user's terminal cyan, which is often much louder. - assert_eq!(nearest_ansi16(53, 120, 229), Color::Blue); - assert_eq!(nearest_ansi16(106, 174, 242), Color::LightBlue); - assert_eq!(nearest_ansi16(42, 74, 127), Color::Blue); - assert_eq!(nearest_ansi16(54, 187, 212), Color::LightCyan); - assert_eq!(nearest_ansi16(226, 80, 96), Color::Red); - assert_eq!(nearest_ansi16(11, 21, 38), Color::Black); + // v0.8.45: accent primary is Signal Gold (#F6C453), secondary is Seafoam. + assert_eq!(nearest_ansi16(246, 196, 83), Color::LightYellow); // Signal Gold + assert_eq!(nearest_ansi16(79, 209, 197), Color::LightCyan); // Seafoam + assert_eq!(nearest_ansi16(42, 74, 127), Color::Blue); // Border + assert_eq!(nearest_ansi16(54, 187, 212), Color::LightCyan); // Aqua + assert_eq!(nearest_ansi16(255, 92, 122), Color::LightRed); // Rose Red + assert_eq!(nearest_ansi16(13, 21, 37), Color::Black); // Deep Navy } #[test] diff --git a/crates/tui/src/pricing.rs b/crates/tui/src/pricing.rs index 750f9830..eb78ed8b 100644 --- a/crates/tui/src/pricing.rs +++ b/crates/tui/src/pricing.rs @@ -201,6 +201,25 @@ fn calculate_turn_cost_from_usage_with_pricing(pricing: CurrencyPricing, usage: hit_cost + miss_cost + output_cost } +/// Estimate how much money was saved by serving `cache_hit_tokens` from the +/// prefix cache instead of billing them at the cache-miss rate. Returns `None` +/// when the model's pricing is unknown or the number of cache-hit tokens is +/// zero (nothing to save). +#[must_use] +pub fn calculate_cache_savings(model: &str, cache_hit_tokens: u32) -> Option { + if cache_hit_tokens == 0 { + return None; + } + let pricing = pricing_for_model(model)?; + let tokens = cache_hit_tokens as f64 / 1_000_000.0; + Some(CostEstimate { + usd: tokens + * (pricing.usd.input_cache_miss_per_million - pricing.usd.input_cache_hit_per_million), + cny: tokens + * (pricing.cny.input_cache_miss_per_million - pricing.cny.input_cache_hit_per_million), + }) +} + /// Format a USD cost for compact display. #[must_use] #[allow(dead_code)] diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 1a08473d..55be26e8 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -868,10 +868,10 @@ impl RuntimeThreadManager { { let mut active = self.active.lock().await; - if let Some(state) = active.engines.get_mut(thread_id) { - if let Some(turn) = state.active_turn.as_mut() { - turn.auto_approve = true; - } + if let Some(state) = active.engines.get_mut(thread_id) + && let Some(turn) = state.active_turn.as_mut() + { + turn.auto_approve = true; } } } diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index c5a69193..93fcb56c 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -132,6 +132,11 @@ pub struct SessionMetadata { /// current saved sessions are linear JSON files, not per-entry trees. #[serde(default, skip_serializing_if = "Option::is_none")] pub forked_from_message_count: Option, + /// Cumulative turn duration in seconds (sum of completed turn elapsed + /// times). Persisted so the footer "worked" chip survives restarts + /// (#2038). + #[serde(default)] + pub cumulative_turn_secs: u64, } /// Cost and high-water-mark fields persisted with each session. @@ -723,6 +728,7 @@ pub fn create_saved_session_with_id_and_mode( cost: SessionCostSnapshot::default(), parent_session_id: None, forked_from_message_count: None, + cumulative_turn_secs: 0, }, messages: capped_messages, system_prompt: merge_truncation_note( @@ -1045,6 +1051,7 @@ mod tests { cost: SessionCostSnapshot::default(), parent_session_id: None, forked_from_message_count: None, + cumulative_turn_secs: 0, }, system_prompt: None, context_references: Vec::new(), @@ -1075,6 +1082,7 @@ mod tests { cost: SessionCostSnapshot::default(), parent_session_id: None, forked_from_message_count: None, + cumulative_turn_secs: 0, }, system_prompt: None, context_references: Vec::new(), diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index d3401071..252fdc7e 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -273,11 +273,6 @@ pub struct Settings { /// `binary_unavailable` response with an install hint, matching the /// pre-v0.8.32 behavior. pub prefer_external_pdftotext: bool, - /// Optional command that records/transcribes voice input and writes the - /// final UTF-8 transcript to stdout. Triggered by the command palette. - pub voice_input_command: Option, - /// Timeout for the configured voice input command, in seconds. - pub voice_input_timeout_secs: u64, } impl Default for Settings { @@ -320,8 +315,6 @@ impl Default for Settings { status_indicator: "whale".to_string(), synchronized_output: "auto".to_string(), prefer_external_pdftotext: false, - voice_input_command: None, - voice_input_timeout_secs: crate::tui::voice_input::default_timeout_secs(), } } } @@ -370,11 +363,6 @@ impl Settings { .to_string(); s.background_color = normalize_optional_background_color(s.background_color.as_deref()); s.theme = normalize_settings_theme(&s.theme).to_string(); - let voice_input_command = - normalize_optional_voice_input_command(s.voice_input_command.as_deref()); - s.voice_input_command = voice_input_command; - s.voice_input_timeout_secs = - crate::tui::voice_input::clamp_timeout_secs(s.voice_input_timeout_secs); s.default_model = s.default_model.as_deref().and_then(normalize_default_model); s.reasoning_effort = s .reasoning_effort @@ -396,15 +384,6 @@ impl Settings { self.low_motion = true; self.fancy_animations = false; } - if let Ok(value) = std::env::var("DEEPSEEK_VOICE_INPUT_COMMAND") { - self.voice_input_command = normalize_optional_voice_input_command(Some(&value)); - } - if let Ok(value) = std::env::var("DEEPSEEK_VOICE_INPUT_TIMEOUT_SECS") - && let Ok(timeout_secs) = value.trim().parse::() - { - self.voice_input_timeout_secs = - crate::tui::voice_input::clamp_timeout_secs(timeout_secs); - } // VS Code (TERM_PROGRAM=vscode, #1356), Ghostty (TERM_PROGRAM=ghostty, // #1445), and a few VTE terminals (#1470) produce visible flicker at // 120 FPS. Drop to the 30 FPS low-motion cap for them automatically. @@ -604,22 +583,6 @@ impl Settings { "prefer_external_pdftotext" | "external_pdftotext" | "pdftotext" => { self.prefer_external_pdftotext = parse_bool(value)?; } - "voice_input_command" | "voice_command" | "dictation_command" => { - self.voice_input_command = normalize_optional_voice_input_command(Some(value)); - } - "voice_input_timeout_secs" | "voice_timeout" | "dictation_timeout" => { - let timeout_secs: u64 = value.parse().map_err(|_| { - anyhow::anyhow!( - "Failed to update setting: invalid voice input timeout '{value}'. Expected a number from 1 to 600." - ) - })?; - if !(1..=600).contains(&timeout_secs) { - anyhow::bail!( - "Failed to update setting: voice input timeout must be between 1 and 600 seconds." - ); - } - self.voice_input_timeout_secs = timeout_secs; - } "default_mode" | "mode" => { let normalized = normalize_mode(value); if !["agent", "plan", "yolo"].contains(&normalized) { @@ -748,16 +711,6 @@ impl Settings { " prefer_external_pdftotext: {}", self.prefer_external_pdftotext )); - lines.push(format!( - " voice_input_command: {}", - self.voice_input_command - .as_deref() - .unwrap_or("(not configured)") - )); - lines.push(format!( - " voice_input_timeout_secs: {}", - self.voice_input_timeout_secs - )); lines.push(format!(" default_mode: {}", self.default_mode)); lines.push(format!( " sidebar_width: {}%", @@ -850,14 +803,6 @@ impl Settings { "prefer_external_pdftotext", "Route PDF reads through Poppler's pdftotext instead of the bundled pure-Rust extractor: on/off (default off)", ), - ( - "voice_input_command", - "Command run by command-palette Voice input; stdout must be the transcript, or none/default to disable", - ), - ( - "voice_input_timeout_secs", - "Voice input command timeout in seconds: 1-600 (default 60)", - ), ("default_mode", "Default mode: agent, plan, yolo"), ("sidebar_width", "Sidebar width percentage: 10-50"), ( @@ -1078,24 +1023,6 @@ fn normalize_background_color_setting(value: &str) -> Result> { }) } -fn normalize_optional_voice_input_command(value: Option<&str>) -> Option { - value.and_then(normalize_voice_input_command) -} - -fn normalize_voice_input_command(value: &str) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() - || matches!( - trimmed.to_ascii_lowercase().as_str(), - "default" | "none" | "off" | "false" | "disabled" - ) - { - None - } else { - Some(trimmed.to_string()) - } -} - fn normalize_sidebar_focus(value: &str) -> &str { match value.trim().to_ascii_lowercase().as_str() { "work" | "plan" | "todos" => "work", @@ -1308,39 +1235,6 @@ mod tests { assert!(!settings.context_panel); } - #[test] - fn voice_input_settings_normalize_and_clear() { - let mut settings = Settings::default(); - assert!(settings.voice_input_command.is_none()); - assert_eq!( - settings.voice_input_timeout_secs, - crate::tui::voice_input::default_timeout_secs() - ); - - settings - .set("voice_input_command", r#"python3 "/tmp/voice helper.py""#) - .expect("set voice command"); - assert_eq!( - settings.voice_input_command.as_deref(), - Some(r#"python3 "/tmp/voice helper.py""#) - ); - - settings - .set("voice_input_timeout_secs", "120") - .expect("set timeout"); - assert_eq!(settings.voice_input_timeout_secs, 120); - - settings - .set("voice_command", "none") - .expect("clear voice command"); - assert!(settings.voice_input_command.is_none()); - - let err = settings - .set("voice_timeout", "0") - .expect_err("timeout must be bounded"); - assert!(err.to_string().contains("between 1 and 600")); - } - #[test] fn display_localizes_header_and_config_file_label() { let settings = Settings::default(); diff --git a/crates/tui/src/skills/install.rs b/crates/tui/src/skills/install.rs index 53e641fb..aa4550be 100644 --- a/crates/tui/src/skills/install.rs +++ b/crates/tui/src/skills/install.rs @@ -391,7 +391,10 @@ pub async fn update_with_registry( network: &NetworkPolicy, registry_url: &str, ) -> Result { - let target = skills_dir.join(name); + let target = skill_target_path(name, skills_dir)?; + if target.exists() { + ensure_target_within_skills_dir(&target, skills_dir)?; + } let marker_path = target.join(INSTALLED_FROM_MARKER); if !marker_path.exists() { return Err(InstallError::NotInstalledHere(name.to_string()).into()); @@ -439,10 +442,11 @@ pub async fn update_with_registry( /// Refuses to touch any directory that doesn't carry the `.installed-from` /// marker — that's our cue that it's user-owned and not a system skill. pub fn uninstall(name: &str, skills_dir: &Path) -> Result<()> { - let target = skills_dir.join(name); + let target = skill_target_path(name, skills_dir)?; if !target.exists() { bail!("skill '{name}' is not installed at {}", target.display()); } + ensure_target_within_skills_dir(&target, skills_dir)?; if !target.join(INSTALLED_FROM_MARKER).exists() { return Err(InstallError::NotInstalledHere(name.to_string()).into()); } @@ -458,10 +462,11 @@ pub fn uninstall(name: &str, skills_dir: &Path) -> Result<()> { /// Refuses to mark system skills (no `.installed-from`) so the bundled /// `skill-creator` doesn't accidentally inherit elevated tool privileges. pub fn trust(name: &str, skills_dir: &Path) -> Result<()> { - let target = skills_dir.join(name); + let target = skill_target_path(name, skills_dir)?; if !target.exists() { bail!("skill '{name}' is not installed at {}", target.display()); } + ensure_target_within_skills_dir(&target, skills_dir)?; if !target.join(INSTALLED_FROM_MARKER).exists() { return Err(InstallError::NotInstalledHere(name.to_string()).into()); } @@ -1343,6 +1348,40 @@ fn is_safe_path(path: &Path) -> bool { true } +fn skill_target_path(name: &str, skills_dir: &Path) -> Result { + let name = validate_skill_name_segment(name)?; + Ok(skills_dir.join(name)) +} + +fn validate_skill_name_segment(name: &str) -> Result<&str> { + if name.is_empty() || name.trim() != name || name.chars().any(char::is_whitespace) { + bail!("skill name must be a single path-safe segment (got '{name}')"); + } + if name == "." || name == ".." || name.contains('/') || name.contains('\\') { + bail!("skill name must be a single path-safe segment (got '{name}')"); + } + let mut components = Path::new(name).components(); + if !matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some() { + bail!("skill name must be a single path-safe segment (got '{name}')"); + } + Ok(name) +} + +fn ensure_target_within_skills_dir(target: &Path, skills_dir: &Path) -> Result<()> { + let skills_dir = fs::canonicalize(skills_dir) + .with_context(|| format!("failed to resolve {}", skills_dir.display()))?; + let target = fs::canonicalize(target) + .with_context(|| format!("failed to resolve {}", target.display()))?; + if !target.starts_with(&skills_dir) { + bail!( + "skill path {} escapes skills directory {}", + target.display(), + skills_dir.display() + ); + } + Ok(()) +} + /// Strip a leading directory prefix (e.g. `repo-main/`) from a tarball path. fn strip_prefix<'a>(path: &'a str, prefix: &str) -> std::borrow::Cow<'a, str> { if prefix.is_empty() { @@ -1394,13 +1433,7 @@ fn parse_frontmatter_name(bytes: &[u8]) -> Result { if !has_description { return Err(InstallError::MissingFrontmatterField("description").into()); } - // Sanity check: name must be a single path-safe segment. - if name.contains('/') - || name.contains('\\') - || name == "." - || name == ".." - || name.contains(' ') - { + if validate_skill_name_segment(&name).is_err() { bail!("SKILL.md `name` must be a single path-safe segment (got '{name}')"); } Ok(name) @@ -1546,6 +1579,9 @@ mod tests { let body = b"---\nname: a name with spaces\ndescription: x\n---\n"; assert!(parse_frontmatter_name(body).is_err()); + + let body = b"---\nname: tab\tname\ndescription: x\n---\n"; + assert!(parse_frontmatter_name(body).is_err()); } #[test] @@ -1554,6 +1590,66 @@ mod tests { assert!(parse_frontmatter_name(body).is_err()); } + #[test] + fn user_skill_names_must_be_single_safe_segments() { + for bad in [ + "", + "../evil", + "/tmp/evil", + "two words", + "two\twords", + "evil/name", + "evil\\name", + ".", + "..", + " leading", + "trailing ", + ] { + assert!( + validate_skill_name_segment(bad).is_err(), + "expected {bad:?} to be rejected" + ); + } + assert_eq!( + validate_skill_name_segment("safe-name_1").unwrap(), + "safe-name_1" + ); + } + + #[test] + fn uninstall_and_trust_reject_unsafe_skill_names_before_path_join() { + let tmp = tempfile::tempdir().expect("tempdir"); + let skills_dir = tmp.path().join("skills"); + std::fs::create_dir_all(&skills_dir).expect("skills dir"); + + for bad in [ + "../evil", + "/tmp/evil", + "evil/name", + "evil\\name", + "two words", + ] { + assert!(uninstall(bad, &skills_dir).is_err()); + assert!(trust(bad, &skills_dir).is_err()); + } + } + + #[cfg(unix)] + #[test] + fn uninstall_rejects_symlink_target_escaping_skills_dir() { + let tmp = tempfile::tempdir().expect("tempdir"); + let skills_dir = tmp.path().join("skills"); + let outside = tmp.path().join("outside"); + std::fs::create_dir_all(&skills_dir).expect("skills dir"); + std::fs::create_dir_all(&outside).expect("outside dir"); + std::fs::write(outside.join(INSTALLED_FROM_MARKER), "{}").expect("marker"); + std::os::unix::fs::symlink(&outside, skills_dir.join("linked")).expect("symlink"); + + let err = uninstall("linked", &skills_dir).unwrap_err(); + assert!(err.to_string().contains("escapes skills directory")); + assert!(outside.exists()); + } + #[test] fn strip_prefix_handles_all_cases() { assert_eq!(strip_prefix("foo/bar", "foo"), "bar"); diff --git a/crates/tui/src/theme_qa_audit.rs b/crates/tui/src/theme_qa_audit.rs new file mode 100644 index 00000000..a19da7e3 --- /dev/null +++ b/crates/tui/src/theme_qa_audit.rs @@ -0,0 +1,324 @@ +//! v0.8.45 theme QA audit — verification script. +//! +//! This module validates: +//! - Every shipped theme has all required semantic palette fields populated. +//! - Error/destructive states are distinct from warm action accents. +//! - Selection, focus, diff, warning, success, and status colors are readable. +//! - Terminal contrast is checked for common truecolor surfaces. +//! +//! Run with: cargo test -p codewhale-tui -- theme_qa + +#[cfg(test)] +mod tests { + use crate::palette::{ + CATPPUCCIN_MOCHA_UI_THEME, DRACULA_UI_THEME, GRAYSCALE_UI_THEME, GRUVBOX_DARK_UI_THEME, + LIGHT_UI_THEME, TOKYO_NIGHT_UI_THEME, UI_THEME, UiTheme, + }; + use ratatui::style::Color; + + /// All shipped themes in display order. + const ALL_THEMES: &[UiTheme] = &[ + UI_THEME, + LIGHT_UI_THEME, + GRAYSCALE_UI_THEME, + CATPPUCCIN_MOCHA_UI_THEME, + TOKYO_NIGHT_UI_THEME, + DRACULA_UI_THEME, + GRUVBOX_DARK_UI_THEME, + ]; + + /// Extract (r, g, b) from a Color::Rgb. Returns None for non-RGB colors. + fn rgb(color: Color) -> Option<(u8, u8, u8)> { + match color { + Color::Rgb(r, g, b) => Some((r, g, b)), + _ => None, + } + } + + /// Relative luminance per WCAG 2.1. + fn relative_luminance(r: u8, g: u8, b: u8) -> f64 { + fn channel(c: u8) -> f64 { + let s = c as f64 / 255.0; + if s <= 0.03928 { + s / 12.92 + } else { + ((s + 0.055) / 1.055).powf(2.4) + } + } + 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b) + } + + /// WCAG 2.1 contrast ratio. + fn contrast_ratio(fg: (u8, u8, u8), bg: (u8, u8, u8)) -> f64 { + let l1 = relative_luminance(fg.0, fg.1, fg.2); + let l2 = relative_luminance(bg.0, bg.1, bg.2); + let (lighter, darker) = if l1 > l2 { (l1, l2) } else { (l2, l1) }; + (lighter + 0.05) / (darker + 0.05) + } + + #[test] + fn all_themes_have_non_default_surface_bg() { + for theme in ALL_THEMES { + assert!( + rgb(theme.surface_bg).is_some(), + "{}: surface_bg must be an RGB color", + theme.name + ); + } + } + + #[test] + fn all_themes_have_required_semantic_fields() { + for theme in ALL_THEMES { + let name = theme.name; + // Every theme must have distinct accent colors. + assert!( + rgb(theme.accent_primary).is_some(), + "{name}: accent_primary missing" + ); + assert!( + rgb(theme.accent_secondary).is_some(), + "{name}: accent_secondary missing" + ); + assert!( + rgb(theme.accent_action).is_some(), + "{name}: accent_action missing" + ); + + // Error/destructive must be separate from action accent. + assert_ne!( + theme.error_fg, theme.accent_action, + "{name}: error_fg should differ from accent_action" + ); + assert_ne!( + theme.error_fg, theme.accent_primary, + "{name}: error_fg should differ from accent_primary" + ); + + // Error fields present. + assert!(rgb(theme.error_fg).is_some(), "{name}: error_fg missing"); + assert!( + rgb(theme.error_hover).is_some(), + "{name}: error_hover missing" + ); + assert!( + rgb(theme.error_surface).is_some(), + "{name}: error_surface missing" + ); + assert!( + rgb(theme.error_border).is_some(), + "{name}: error_border missing" + ); + assert!( + rgb(theme.error_text).is_some(), + "{name}: error_text missing" + ); + + // Warning / success / info present. + assert!(rgb(theme.warning).is_some(), "{name}: warning missing"); + assert!(rgb(theme.success).is_some(), "{name}: success missing"); + assert!(rgb(theme.info).is_some(), "{name}: info missing"); + + // Diff colors present. + assert!( + rgb(theme.diff_added_fg).is_some(), + "{name}: diff_added_fg missing" + ); + assert!( + rgb(theme.diff_deleted_fg).is_some(), + "{name}: diff_deleted_fg missing" + ); + assert!( + rgb(theme.diff_added_bg).is_some(), + "{name}: diff_added_bg missing" + ); + assert!( + rgb(theme.diff_deleted_bg).is_some(), + "{name}: diff_deleted_bg missing" + ); + + // Tool colors present. + assert!( + rgb(theme.tool_running).is_some(), + "{name}: tool_running missing" + ); + assert!( + rgb(theme.tool_success).is_some(), + "{name}: tool_success missing" + ); + assert!( + rgb(theme.tool_failed).is_some(), + "{name}: tool_failed missing" + ); + } + } + + #[test] + fn body_text_has_minimum_contrast_on_surface() { + for theme in ALL_THEMES { + let name = theme.name; + let Some(fg) = rgb(theme.text_body) else { + continue; + }; + let Some(bg) = rgb(theme.surface_bg) else { + continue; + }; + let cr = contrast_ratio(fg, bg); + assert!( + cr >= 4.5, + "{name}: body text contrast {cr:.1}:1 is below 4.5:1 minimum (fg={fg:?}, bg={bg:?})" + ); + } + } + + #[test] + fn muted_text_is_readable_on_surface() { + for theme in ALL_THEMES { + let name = theme.name; + let Some(fg) = rgb(theme.text_muted) else { + continue; + }; + let Some(bg) = rgb(theme.surface_bg) else { + continue; + }; + let cr = contrast_ratio(fg, bg); + assert!( + cr >= 3.0, + "{name}: muted text contrast {cr:.1}:1 is below 3.0:1 minimum (fg={fg:?}, bg={bg:?})" + ); + } + } + + #[test] + fn error_text_contrasts_on_error_surface() { + for theme in ALL_THEMES { + let name = theme.name; + let Some(fg) = rgb(theme.error_text) else { + continue; + }; + let Some(bg) = rgb(theme.error_surface) else { + continue; + }; + let cr = contrast_ratio(fg, bg); + assert!( + cr >= 4.5, + "{name}: error_text on error_surface contrast {cr:.1}:1 is below 4.5:1" + ); + } + } + + #[test] + fn selection_bg_differs_from_surface_bg() { + for theme in ALL_THEMES { + let name = theme.name; + assert_ne!( + theme.selection_bg, theme.surface_bg, + "{name}: selection_bg must differ from surface_bg" + ); + } + } + + #[test] + fn surface_layers_are_distinct() { + for theme in ALL_THEMES { + let name = theme.name; + // Panel should be distinct from surface (unless grayscale which has limited range). + if theme.name != "grayscale" { + assert_ne!( + theme.panel_bg, theme.surface_bg, + "{name}: panel_bg must differ from surface_bg for visual layering" + ); + } + } + } + + #[test] + fn success_and_warning_are_visually_distinct() { + for theme in ALL_THEMES { + let name = theme.name; + assert_ne!( + theme.success, theme.warning, + "{name}: success and warning must be distinct colors" + ); + assert_ne!( + theme.success, theme.error_fg, + "{name}: success and error must be distinct colors" + ); + } + } + + #[test] + fn diff_added_and_deleted_are_distinct() { + for theme in ALL_THEMES { + let name = theme.name; + assert_ne!( + theme.diff_added_fg, theme.diff_deleted_fg, + "{name}: diff add/del fg must differ" + ); + assert_ne!( + theme.diff_added_bg, theme.diff_deleted_bg, + "{name}: diff add/del bg must differ" + ); + } + } + + #[test] + fn mode_colors_are_all_distinct() { + for theme in ALL_THEMES { + let name = theme.name; + let modes = [ + ("agent", theme.mode_agent), + ("yolo", theme.mode_yolo), + ("plan", theme.mode_plan), + ("goal", theme.mode_goal), + ]; + for i in 0..modes.len() { + for j in (i + 1)..modes.len() { + assert_ne!( + modes[i].1, modes[j].1, + "{name}: mode {} and mode {} have same color", + modes[i].0, modes[j].0 + ); + } + } + } + } + + #[test] + fn whale_dark_uses_proposed_palette() { + // Issue #2012: verify the default Whale dark uses proposed tokens. + let t = UI_THEME; + assert_eq!(rgb(t.surface_bg), Some((13, 21, 37)), "Deep Navy #0D1525"); + assert_eq!( + rgb(t.text_body), + Some((246, 242, 232)), + "Whale Ivory #F6F2E8" + ); + assert_eq!( + rgb(t.text_muted), + Some((169, 180, 199)), + "Mist Gray #A9B4C7" + ); + assert_eq!( + rgb(t.accent_primary), + Some((246, 196, 83)), + "Signal Gold #F6C453" + ); + assert_eq!( + rgb(t.accent_secondary), + Some((79, 209, 197)), + "Seafoam #4FD1C5" + ); + assert_eq!( + rgb(t.accent_action), + Some((255, 122, 89)), + "Coral Spark #FF7A59" + ); + assert_eq!(rgb(t.error_fg), Some((255, 92, 122)), "Rose Red #FF5C7A"); + assert_eq!( + rgb(t.error_surface), + Some((42, 18, 26)), + "Error Surface #2A121A" + ); + } +} diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index f0368bf5..68cc3539 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -129,18 +129,6 @@ pub enum AppMode { Plan, } -#[derive(Debug, Clone)] -pub struct VoiceInputState { - pub started_at: Instant, -} - -impl VoiceInputState { - #[must_use] - pub fn new(started_at: Instant) -> Self { - Self { started_at } - } -} - /// One row in the per-turn cache-telemetry ring (`/cache` debug surface, #263). #[derive(Debug, Clone)] pub struct TurnCacheRecord { @@ -1090,8 +1078,6 @@ pub struct App { pub sticky_status: Option, /// Last status text already promoted from `status_message` into toast state. pub last_status_message_seen: Option, - /// Active external speech-to-text helper launched from the command palette. - pub voice_input_state: Option, pub model: String, /// When true, the model is auto-selected based on request complexity /// rather than using a fixed model. The `/model auto` command sets this. @@ -1816,7 +1802,6 @@ impl App { status_toasts: VecDeque::new(), sticky_status: None, last_status_message_seen: None, - voice_input_state: None, model, auto_model, last_effective_model: None, @@ -2220,6 +2205,9 @@ impl App { metadata.cost.subagent_cost_cny = self.session.subagent_cost_cny; metadata.cost.displayed_cost_high_water_usd = self.session.displayed_cost_high_water; metadata.cost.displayed_cost_high_water_cny = self.session.displayed_cost_high_water_cny; + // Persist cumulative turn duration so the footer "worked" chip + // survives session save/restore (#2038). + metadata.cumulative_turn_secs = self.cumulative_turn_duration.as_secs(); } /// Recompute the displayed cost high-water mark. Called any time a cost @@ -2279,6 +2267,18 @@ impl App { crate::pricing::format_cost_amount_precise(amount, self.cost_currency) } + /// Estimated cost saved by the last turn's cache-hit tokens in the + /// configured display currency. Returns `None` when the model's pricing + /// is unknown or there were no cache hits. + pub fn last_turn_cache_savings(&self) -> Option { + let hit_tokens = self.session.last_prompt_cache_hit_tokens?; + let estimate = crate::pricing::calculate_cache_savings(&self.model, hit_tokens)?; + Some(match self.cost_currency { + crate::pricing::CostCurrency::Usd => estimate.usd, + crate::pricing::CostCurrency::Cny => estimate.cny, + }) + } + /// Fold the oldest [`Self::HISTORY_FOLD_BATCH`] cells into a single /// `ArchivedContext` placeholder when history exceeds the soft cap. /// Called from [`Self::add_message`]; the caller is responsible for diff --git a/crates/tui/src/tui/color_compat.rs b/crates/tui/src/tui/color_compat.rs index 68c367f2..73a253aa 100644 --- a/crates/tui/src/tui/color_compat.rs +++ b/crates/tui/src/tui/color_compat.rs @@ -255,7 +255,7 @@ mod tests { fn light_palette_maps_dark_cells_before_depth_adaptation() { let mut cell = Cell::default(); cell.set_fg(Color::White); - cell.set_bg(Color::Rgb(11, 21, 38)); + cell.set_bg(palette::DEEPSEEK_INK); adapt_cell_colors( &mut cell, diff --git a/crates/tui/src/tui/command_palette.rs b/crates/tui/src/tui/command_palette.rs index 4af59bcf..f1e5bb04 100644 --- a/crates/tui/src/tui/command_palette.rs +++ b/crates/tui/src/tui/command_palette.rs @@ -55,14 +55,6 @@ pub fn build_entries( ) -> Vec { let mut entries = Vec::new(); - entries.push(CommandPaletteEntry { - section: PaletteSection::Action, - label: "Voice input".to_string(), - description: "Listen, transcribe, and insert editable text into the composer".to_string(), - command: "voice input dictate microphone speech".to_string(), - action: CommandPaletteAction::VoiceInput, - }); - for command in commands::COMMANDS { let mut description = command.palette_description_for(locale); if command.requires_argument() { @@ -1017,24 +1009,6 @@ mod tests { assert!(!command_labels.contains(&"/deepseek")); } - #[test] - fn command_palette_includes_voice_input_action() { - let entries = build_entries( - Locale::En, - Path::new("."), - Path::new("."), - Path::new("mcp.json"), - None, - ); - let voice = entries - .iter() - .find(|entry| entry.section == PaletteSection::Action && entry.label == "Voice input") - .expect("voice input action"); - - assert!(voice.description.contains("composer")); - assert!(matches!(voice.action, CommandPaletteAction::VoiceInput)); - } - #[test] fn command_palette_inserts_model_command_for_argument_entry() { let entries = build_entries( diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index 3b0c3ebd..0269c8de 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -72,8 +72,7 @@ pub(crate) fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { // Surface one compact live status row in the footer whenever a turn // is live. Tool turns get the current action plus active/done counts; // non-tool work falls back to the existing dot-pulse label. - let mut label = active_voice_input_status_label(app, now_ms) - .or_else(|| active_subagent_status_label(app)) + let mut label = active_subagent_status_label(app) .or_else(|| active_tool_status_label(app)) .unwrap_or_else(|| crate::tui::widgets::footer_working_label(dot_frame, app.ui_locale)); // Append stall reason when the turn has been running > 30 s. @@ -156,47 +155,16 @@ pub(crate) fn stall_reason(app: &App) -> Option<&'static str> { /// though the agent is still working. pub(crate) fn footer_working_strip_active(app: &App) -> bool { let turn_in_progress = app.runtime_turn_status.as_deref() == Some("in_progress"); - app.is_loading - || app.is_compacting - || running_agent_count(app) > 0 - || turn_in_progress - || app.voice_input_state.is_some() + app.is_loading || app.is_compacting || running_agent_count(app) > 0 || turn_in_progress } pub(crate) fn footer_working_label_frame(now_ms: u64, fancy_animations: bool) -> u64 { if fancy_animations { now_ms / 400 } else { 0 } } -pub(crate) fn active_voice_input_status_label(app: &App, now_ms: u64) -> Option { - let state = app.voice_input_state.as_ref()?; - let elapsed = state.started_at.elapsed().as_secs(); - Some(voice_input_status_text( - app.fancy_animations, - elapsed, - now_ms, - )) -} - -pub(crate) fn voice_input_status_text( - fancy_animations: bool, - elapsed_secs: u64, - now_ms: u64, -) -> String { - if !fancy_animations { - return format!("listening/transcribing {elapsed_secs}s"); - } - let dots = match (now_ms / 300) % 4 { - 0 => "", - 1 => ".", - 2 => "..", - _ => "...", - }; - format!("listening/transcribing{dots} {elapsed_secs}s") -} - #[cfg(test)] mod tests { - use super::{footer_working_label_frame, voice_input_status_text}; + use super::footer_working_label_frame; #[test] fn footer_working_label_frame_is_static_without_fancy_animations() { @@ -205,15 +173,6 @@ mod tests { assert_eq!(footer_working_label_frame(1_600, false), 0); assert_eq!(footer_working_label_frame(1_600, true), 4); } - - #[test] - fn voice_input_status_label_animates_when_enabled() { - let first = voice_input_status_text(true, 2, 0); - let second = voice_input_status_text(true, 2, 300); - - assert_ne!(first, second); - assert!(first.contains("listening/transcribing")); - } } pub(crate) fn is_noisy_subagent_progress(status: &str) -> bool { @@ -583,10 +542,21 @@ pub(crate) fn footer_cost_spans(app: &App) -> Vec> { if !should_show_footer_cost(displayed_cost) { return Vec::new(); } - vec![Span::styled( + let mut spans = vec![Span::styled( app.format_cost_amount(displayed_cost), Style::default().fg(palette::TEXT_MUTED), - )] + )]; + // Append cache-savings hint when the last turn had cache hits that + // saved money (#2038). + if let Some(saved) = app.last_turn_cache_savings() + && saved > 0.0 + { + spans.push(Span::styled( + format!(" · saved {}", app.format_cost_amount(saved)), + Style::default().fg(palette::STATUS_SUCCESS), + )); + } + spans } pub(crate) fn should_show_footer_cost(displayed_cost: f64) -> bool { diff --git a/crates/tui/src/tui/markdown_render.rs b/crates/tui/src/tui/markdown_render.rs index 0ad25467..e9c92e3a 100644 --- a/crates/tui/src/tui/markdown_render.rs +++ b/crates/tui/src/tui/markdown_render.rs @@ -1571,7 +1571,7 @@ mod tests { fn table_pipes_inside_inline_code_stay_in_the_cell() { let src = "| Check | Result |\n\ |---|---|\n\ - | `strings ~/.cargo/bin/codewhale-tui | grep -c \"Goal mode\"` | 0 matches |\n"; + | `strings ~/.cargo/bin/codewhale-tui | grep -c \"legacy marker\"` | 0 matches |\n"; let parsed = parse(src); let rows: Vec<&Vec> = parsed @@ -1587,7 +1587,7 @@ mod tests { assert_eq!( rows[1], &vec![ - "`strings ~/.cargo/bin/codewhale-tui | grep -c \"Goal mode\"`".to_string(), + "`strings ~/.cargo/bin/codewhale-tui | grep -c \"legacy marker\"`".to_string(), "0 matches".to_string(), ] ); diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index d36b81cd..34b70ee2 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -70,7 +70,6 @@ mod ui_text; pub mod user_input; pub mod views; pub mod vim_mode; -pub mod voice_input; pub mod widgets; pub mod workspace_context; diff --git a/crates/tui/src/tui/session_picker.rs b/crates/tui/src/tui/session_picker.rs index f6a80639..1cfbad95 100644 --- a/crates/tui/src/tui/session_picker.rs +++ b/crates/tui/src/tui/session_picker.rs @@ -952,6 +952,7 @@ mod tests { cost: crate::session_manager::SessionCostSnapshot::default(), parent_session_id: None, forked_from_message_count: None, + cumulative_turn_secs: 0, } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 852e6c4d..61cb195c 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -108,7 +108,7 @@ use crate::tui::workspace_context; use super::app::{ App, AppAction, AppMode, OnboardingState, QueuedMessage, ReasoningEffort, SidebarFocus, - StatusToastLevel, SubmitDisposition, TaskPanelEntry, TuiOptions, VoiceInputState, + StatusToastLevel, SubmitDisposition, TaskPanelEntry, TuiOptions, looks_like_slash_command_input, }; use super::approval::{ @@ -195,10 +195,6 @@ enum TranslationEvent { }, } -#[derive(Debug)] -enum VoiceInputEvent { - Finished { result: Result }, -} // Reset scroll region (`\x1b[r`), origin mode (`\x1b[?6l`), and home the cursor // (`\x1b[H`) before letting ratatui's diff renderer repaint. The destructive // `\x1b[2J\x1b[3J` pair was previously appended here to also wipe the visible @@ -870,8 +866,6 @@ async fn run_event_loop( let mut current_streaming_text = String::new(); let (translation_tx, mut translation_rx) = tokio::sync::mpsc::unbounded_channel::(); - let (voice_input_tx, mut voice_input_rx) = - tokio::sync::mpsc::unbounded_channel::(); let mut pending_translations = 0usize; let mut pending_thinking_translations = 0usize; let mut last_queue_state = (app.queued_messages.clone(), app.queued_draft.clone()); @@ -991,8 +985,6 @@ async fn run_event_loop( } } - drain_voice_input_events(app, &mut voice_input_rx); - if last_task_refresh.elapsed() >= Duration::from_millis(2500) { refresh_active_task_panel(app, &task_manager).await; last_task_refresh = Instant::now(); @@ -2007,7 +1999,6 @@ async fn run_event_loop( &task_manager, &mut engine_handle, &mut web_config_session, - voice_input_tx.clone(), events, ) .await? @@ -2020,10 +2011,7 @@ async fn run_event_loop( if reconcile_turn_liveness(app, Instant::now(), has_running_agents) { app.needs_redraw = true; } - if (app.is_loading - || has_running_agents - || app.is_compacting - || app.voice_input_state.is_some()) + if (app.is_loading || has_running_agents || app.is_compacting) && last_status_frame.elapsed() >= Duration::from_millis(status_animation_interval_ms(app)) { @@ -2117,11 +2105,7 @@ async fn run_event_loop( app.needs_redraw = false; } - let mut poll_timeout = if app.is_loading - || has_running_agents - || app.is_compacting - || app.voice_input_state.is_some() - { + let mut poll_timeout = if app.is_loading || has_running_agents || app.is_compacting { Duration::from_millis(active_poll_ms(app)) } else { Duration::from_millis(idle_poll_ms(app)) @@ -2306,7 +2290,6 @@ async fn run_event_loop( &task_manager, &mut engine_handle, &mut web_config_session, - voice_input_tx.clone(), events, ) .await? @@ -2688,7 +2671,6 @@ async fn run_event_loop( &task_manager, &mut engine_handle, &mut web_config_session, - voice_input_tx.clone(), events, ) .await? @@ -5291,82 +5273,6 @@ async fn execute_command_input( .await } -fn start_voice_input( - app: &mut App, - voice_input_tx: tokio::sync::mpsc::UnboundedSender, -) { - if app.voice_input_state.is_some() { - app.status_message = Some("Voice input is already listening".to_string()); - app.needs_redraw = true; - return; - } - - let settings = match crate::settings::Settings::load() { - Ok(settings) => settings, - Err(err) => { - app.add_message(HistoryCell::System { - content: format!("Voice input unavailable: failed to load settings: {err}"), - }); - app.status_message = Some("Voice input unavailable".to_string()); - return; - } - }; - - let Some(command_line) = settings.voice_input_command.clone() else { - app.add_message(HistoryCell::System { - content: "Voice input is not configured. Set `voice_input_command` in settings.toml or export `DEEPSEEK_VOICE_INPUT_COMMAND`. Open the command palette and choose Voice input after configuring it. The command must write the transcript to stdout.".to_string(), - }); - app.status_message = Some("Voice input not configured".to_string()); - return; - }; - - let timeout_secs = settings.voice_input_timeout_secs; - let workspace = app.workspace.clone(); - app.voice_input_state = Some(VoiceInputState::new(Instant::now())); - app.status_message = - Some("Voice input listening - transcript will appear in the composer".to_string()); - app.needs_redraw = true; - - tokio::spawn(async move { - let result = crate::tui::voice_input::run_configured_voice_command( - &command_line, - timeout_secs, - &workspace, - ) - .await; - let _ = voice_input_tx.send(VoiceInputEvent::Finished { result }); - }); -} - -fn drain_voice_input_events( - app: &mut App, - voice_input_rx: &mut tokio::sync::mpsc::UnboundedReceiver, -) { - while let Ok(event) = voice_input_rx.try_recv() { - match event { - VoiceInputEvent::Finished { result } => { - app.voice_input_state = None; - match result { - Ok(transcript) => { - let char_count = transcript.chars().count(); - app.insert_str(&transcript); - app.status_message = Some(format!( - "Voice transcript inserted ({char_count} chars) - edit, then Enter to send" - )); - } - Err(err) => { - app.add_message(HistoryCell::System { - content: format!("Voice input failed: {err}"), - }); - app.status_message = Some("Voice input failed".to_string()); - } - } - app.needs_redraw = true; - } - } - } -} - async fn steer_user_message( app: &mut App, engine_handle: &EngineHandle, @@ -6009,7 +5915,6 @@ async fn handle_view_events( task_manager: &SharedTaskManager, engine_handle: &mut EngineHandle, web_config_session: &mut Option, - voice_input_tx: tokio::sync::mpsc::UnboundedSender, events: Vec, ) -> Result { for event in events { @@ -6040,9 +5945,6 @@ async fn handle_view_events( crate::tui::views::CommandPaletteAction::OpenTextPager { title, content } => { open_text_pager(app, title, content); } - crate::tui::views::CommandPaletteAction::VoiceInput => { - start_voice_input(app, voice_input_tx.clone()); - } }, ViewEvent::OpenTextPager { title, content } => { open_text_pager(app, title, content); @@ -6734,6 +6636,10 @@ fn apply_loaded_session(app: &mut App, config: &Config, session: &SavedSession) app.session.last_prompt_cache_miss_tokens = None; app.session.last_reasoning_replay_tokens = None; app.session.turn_cache_history.clear(); + // Restore cumulative turn duration so the footer "worked" chip + // persists across session restarts (#2038). + app.cumulative_turn_duration = + std::time::Duration::from_secs(session.metadata.cumulative_turn_secs); app.current_session_id = Some(session.metadata.id.clone()); app.session_artifacts = session.artifacts.clone(); app.session_title = Some(session.metadata.title.clone()); diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 6b983961..2a254c67 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1286,6 +1286,7 @@ fn saved_session_with_messages(messages: Vec) -> SavedSession { cost: crate::session_manager::SessionCostSnapshot::default(), parent_session_id: None, forked_from_message_count: None, + cumulative_turn_secs: 0, }, messages, system_prompt: None, diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index e9b91740..beb044be 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -45,7 +45,6 @@ pub enum CommandPaletteAction { ExecuteCommand { command: String }, InsertText { text: String }, OpenTextPager { title: String, content: String }, - VoiceInput, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -751,23 +750,6 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, - ConfigRow { - section: ConfigSection::Composer, - key: "voice_input_command".to_string(), - value: settings - .voice_input_command - .clone() - .unwrap_or_else(|| "(not configured)".to_string()), - editable: true, - scope: ConfigScope::Saved, - }, - ConfigRow { - section: ConfigSection::Composer, - key: "voice_input_timeout_secs".to_string(), - value: settings.voice_input_timeout_secs.to_string(), - editable: true, - scope: ConfigScope::Saved, - }, ConfigRow { section: ConfigSection::Sidebar, key: "sidebar_width".to_string(), @@ -1151,8 +1133,6 @@ fn config_hint_for_key(key: &str) -> &'static str { "max_history" => "integer (0 allowed)", "default_model" => "deepseek-v4-pro | deepseek-v4-flash | deepseek-* | none/default", "reasoning_effort" => "auto | off | low | medium | high | max | default", - "voice_input_command" => "command string | none/default", - "voice_input_timeout_secs" => "1..=600", "mcp_config_path" => "path to mcp.json", _ => "", } @@ -2206,8 +2186,6 @@ mod tests { assert!(keys.contains(&"composer_border")); assert!(keys.contains(&"composer_vim_mode")); assert!(keys.contains(&"bracketed_paste")); - assert!(keys.contains(&"voice_input_command")); - assert!(keys.contains(&"voice_input_timeout_secs")); assert!(keys.contains(&"context_panel")); assert!(keys.contains(&"cost_currency")); assert!(keys.contains(&"prefer_external_pdftotext")); diff --git a/crates/tui/src/tui/voice_input.rs b/crates/tui/src/tui/voice_input.rs deleted file mode 100644 index 04f57e8a..00000000 --- a/crates/tui/src/tui/voice_input.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! Voice-input command bridge for the composer. -//! -//! CodeWhale stays out of platform microphone APIs here. A configured command -//! owns recording and speech-to-text, writes the final transcript to stdout, -//! and the TUI inserts that transcript into the composer. - -use std::path::Path; -use std::process::Stdio; -use std::time::Duration; - -use anyhow::{Context, Result, anyhow}; -use tokio::process::Command as TokioCommand; - -const DEFAULT_TIMEOUT_SECS: u64 = 60; -const MAX_TIMEOUT_SECS: u64 = 600; - -pub(crate) fn clamp_timeout_secs(secs: u64) -> u64 { - secs.clamp(1, MAX_TIMEOUT_SECS) -} - -pub(crate) fn default_timeout_secs() -> u64 { - DEFAULT_TIMEOUT_SECS -} - -fn parse_voice_command(command_line: &str) -> Result<(String, Vec)> { - let trimmed = command_line.trim(); - if trimmed.is_empty() { - return Err(anyhow!("voice_input_command is empty")); - } - - let parts = shlex::split(trimmed).ok_or_else(|| { - anyhow!("voice_input_command has invalid quoting; check spaces and quote pairs") - })?; - let Some((program, args)) = parts.split_first() else { - return Err(anyhow!("voice_input_command is empty")); - }; - Ok((program.clone(), args.to_vec())) -} - -fn stdout_to_transcript(stdout: &[u8]) -> Option { - let text = String::from_utf8_lossy(stdout); - let transcript = text.trim(); - (!transcript.is_empty()).then(|| transcript.to_string()) -} - -fn stderr_summary(stderr: &[u8]) -> String { - let text = String::from_utf8_lossy(stderr); - let trimmed = text.trim(); - if trimmed.is_empty() { - return String::new(); - } - let mut summary: String = trimmed.chars().take(300).collect(); - if trimmed.chars().count() > 300 { - summary.push_str("..."); - } - format!(": {summary}") -} - -pub(crate) async fn run_configured_voice_command( - command_line: &str, - timeout_secs: u64, - cwd: &Path, -) -> Result { - let timeout_secs = clamp_timeout_secs(timeout_secs); - let (program, args) = parse_voice_command(command_line)?; - - let mut command = TokioCommand::new(&program); - command - .args(args) - .current_dir(cwd) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); - - let output = tokio::time::timeout(Duration::from_secs(timeout_secs), command.output()) - .await - .map_err(|_| anyhow!("voice input command timed out after {timeout_secs}s"))? - .with_context(|| format!("failed to run voice input command `{program}`"))?; - - if !output.status.success() { - return Err(anyhow!( - "voice input command exited with {}{}", - output.status, - stderr_summary(&output.stderr) - )); - } - - stdout_to_transcript(&output.stdout) - .ok_or_else(|| anyhow!("voice input command produced no transcript on stdout")) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_quoted_voice_command() { - let (program, args) = - parse_voice_command(r#"python3 "/tmp/codewhale voice.py" --lang en-US"#) - .expect("parse command"); - assert_eq!(program, "python3"); - assert_eq!(args, vec!["/tmp/codewhale voice.py", "--lang", "en-US"]); - } - - #[test] - fn rejects_invalid_voice_command_quoting() { - let err = parse_voice_command(r#"python3 "unterminated"#).expect_err("bad quotes"); - assert!(err.to_string().contains("invalid quoting")); - } - - #[test] - fn trims_stdout_to_transcript() { - assert_eq!( - stdout_to_transcript(b"\n ship the voice input feature\r\n").as_deref(), - Some("ship the voice input feature") - ); - assert!(stdout_to_transcript(b"\n\t ").is_none()); - } - - #[test] - fn timeout_clamps_to_supported_range() { - assert_eq!(clamp_timeout_secs(0), 1); - assert_eq!(clamp_timeout_secs(30), 30); - assert_eq!(clamp_timeout_secs(999), MAX_TIMEOUT_SECS); - } -} diff --git a/crates/tui/tests/palette_audit.rs b/crates/tui/tests/palette_audit.rs index f8cc2805..e86c207a 100644 --- a/crates/tui/tests/palette_audit.rs +++ b/crates/tui/tests/palette_audit.rs @@ -1,8 +1,8 @@ //! Palette audit tests to prevent color drift. //! //! These tests ensure that deprecated colors (like DEEPSEEK_AQUA) are not used -//! directly in user-visible code. The palette should only use DeepSeek brand -//! colors: blue, sky, red (plus neutral shades). +//! directly in user-visible code. Backward-compatible DeepSeek aliases should +//! point at the current CodeWhale semantic tokens instead of stale brand RGBs. use std::fs; use std::path::Path; @@ -133,35 +133,35 @@ fn audit_no_direct_aqua_usage() { } #[test] -fn verify_status_success_uses_sky() { - let manifest_dir = env!("CARGO_MANIFEST_DIR"); - let palette_path = Path::new(manifest_dir).join("src/palette.rs"); - let content = fs::read_to_string(&palette_path).expect("Failed to read palette.rs"); - - assert!( - content.contains("pub const STATUS_SUCCESS: Color = DEEPSEEK_SKY;"), - "STATUS_SUCCESS should use DEEPSEEK_SKY, not DEEPSEEK_AQUA" +fn verify_status_success_uses_success_token() { + assert_eq!( + palette::STATUS_SUCCESS, + Color::Rgb( + palette::WHALE_SUCCESS_RGB.0, + palette::WHALE_SUCCESS_RGB.1, + palette::WHALE_SUCCESS_RGB.2 + ), + "STATUS_SUCCESS should use the current success token" + ); + assert_ne!( + palette::STATUS_SUCCESS, + palette::DEEPSEEK_AQUA, + "STATUS_SUCCESS should not regress to deprecated aqua" ); } #[test] -fn verify_brand_colors_defined() { - let manifest_dir = env!("CARGO_MANIFEST_DIR"); - let palette_path = Path::new(manifest_dir).join("src/palette.rs"); - let content = fs::read_to_string(&palette_path).expect("Failed to read palette.rs"); +fn verify_brand_aliases_follow_whale_tokens() { + assert_eq!(palette::WHALE_ACCENT_PRIMARY_RGB, (246, 196, 83)); + assert_eq!(palette::WHALE_INFO_RGB, (106, 174, 242)); + assert_eq!(palette::WHALE_ERROR_RGB, (255, 92, 122)); - assert!( - content.contains("DEEPSEEK_BLUE_RGB: (u8, u8, u8) = (53, 120, 229);"), - "DEEPSEEK_BLUE should be #3578E5" - ); - assert!( - content.contains("DEEPSEEK_SKY_RGB: (u8, u8, u8) = (106, 174, 242);"), - "DEEPSEEK_SKY should be #6AAEF2" - ); - assert!( - content.contains("DEEPSEEK_RED_RGB: (u8, u8, u8) = (226, 80, 96);"), - "DEEPSEEK_RED should be #E25060" + assert_eq!( + palette::DEEPSEEK_BLUE_RGB, + palette::WHALE_ACCENT_PRIMARY_RGB ); + assert_eq!(palette::DEEPSEEK_SKY_RGB, palette::WHALE_INFO_RGB); + assert_eq!(palette::DEEPSEEK_RED_RGB, palette::WHALE_ERROR_RGB); } #[test] diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 858bac7e..c63e5b9b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -63,23 +63,26 @@ provider's keyring entry. For hosted, generic OpenAI-compatible, or self-hosted providers, set `provider = "nvidia-nim"`, `"openai"`, `"atlascloud"`, `"wanjie-ark"`, `"fireworks"`, -`"sglang"`, `"vllm"`, or `"ollama"` or pass `codewhale --provider `. The facade saves provider -credentials to the shared user config and forwards the resolved key, base URL, -provider, and model to the TUI process. Use +`"moonshot"`, `"sglang"`, `"vllm"`, or `"ollama"` or pass `codewhale --provider `. +The facade saves provider credentials to the shared user config and forwards +the resolved key, base URL, provider, and model to the TUI process. Use `codewhale auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"` or `codewhale auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY"` or `codewhale auth set --provider atlascloud --api-key "YOUR_ATLASCLOUD_API_KEY"` or `codewhale auth set --provider wanjie-ark --api-key "YOUR_WANJIE_API_KEY"` or -`codewhale auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"` to -save provider keys through the facade. The generic `openai` provider defaults -to `https://api.openai.com/v1`, accepts `OPENAI_BASE_URL`, and passes model IDs -through unchanged for OpenAI-compatible gateways. `atlascloud` defaults to +`codewhale auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"` or +`codewhale auth set --provider moonshot --api-key "YOUR_MOONSHOT_OR_KIMI_API_KEY"` +to save provider keys through the facade. The generic `openai` provider defaults +to `https://api.openai.com/v1`, accepts `OPENAI_BASE_URL`, and defaults to +`deepseek-v4-pro` for OpenAI-compatible gateways. `atlascloud` defaults to `https://api.atlascloud.ai/v1`, accepts `ATLASCLOUD_BASE_URL`, and uses `deepseek-ai/deepseek-v4-flash` as its default model. `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`, defaults to `deepseek-reasoner`, and passes model IDs through unchanged because Wanjie model access is -account-scoped. SGLang, vLLM, and Ollama are +account-scoped. `moonshot` targets Moonshot/Kimi, defaults to `kimi-k2.6`, +and can use `KIMI_API_KEY` or `auth_mode = "kimi_oauth"` with local Kimi CLI +credentials. SGLang, vLLM, and Ollama are self-hosted and can run without an API key by default. Ollama defaults to `http://localhost:11434/v1` and sends model tags such as `codewhale-coder:1.3b` or `qwen2.5-coder:7b` unchanged. Self-hosted providers and loopback custom @@ -202,7 +205,7 @@ fallbacks after saved config and keyring credentials: - `DEEPSEEK_API_KEY` - `DEEPSEEK_BASE_URL` - `DEEPSEEK_HTTP_HEADERS` (custom model request headers, comma-separated `name=value` pairs) -- `DEEPSEEK_PROVIDER` (`codewhale|nvidia-nim|openai|atlascloud|wanjie-ark|openrouter|novita|fireworks|sglang|vllm|ollama`) +- `DEEPSEEK_PROVIDER` (`codewhale|nvidia-nim|openai|atlascloud|wanjie-ark|openrouter|novita|fireworks|moonshot|sglang|vllm|ollama`) - `DEEPSEEK_MODEL` or `DEEPSEEK_DEFAULT_TEXT_MODEL` - `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` (stream idle timeout in seconds; default `300`, clamped to `1..=3600`) - `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` (connection setup + response-header wait in seconds; default `45`, clamped to `5..=300`; distinct from the per-chunk idle timeout) @@ -250,8 +253,6 @@ fallbacks after saved config and keyring credentials: - `DEEPSEEK_FORCE_HTTP1` (`1|true|yes|on` pins the HTTP client to HTTP/1.1, disabling HTTP/2; useful on Windows or behind proxies that mishandle long-lived H2 streams) - `DEEPSEEK_HOME` (override the base data directory; defaults to `~/.deepseek`) - `DEEPSEEK_AUTOMATIONS_DIR` (override the automations storage directory; defaults to `~/.deepseek/automations`) -- `DEEPSEEK_VOICE_INPUT_COMMAND` (command used by command-palette Voice input; stdout must be the final transcript) -- `DEEPSEEK_VOICE_INPUT_TIMEOUT_SECS` (voice input command timeout, clamped to `1..=600`, default `60`) - `DEEPSEEK_CAPACITY_ENABLED` - `DEEPSEEK_CAPACITY_LOW_RISK_MAX` - `DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX` @@ -372,59 +373,11 @@ Common settings keys: - `max_history` (number of submitted input history entries; cleared drafts are also kept locally for composer history search) - `default_model` (model name override) -- `voice_input_command` (command run by command-palette Voice input; stdout is - inserted into the composer as transcript text) -- `voice_input_timeout_secs` (1-600 seconds, default 60) Only `agent`, `plan`, and `yolo` are visible modes in the UI. Switch between them with `/mode`. For compatibility, older settings files with `default_mode = "normal"` still load as `agent`. -### Voice Input - -Voice input is intentionally a command bridge instead of a built-in speech SDK. -The configured command owns microphone permission, recording, and -speech-to-text. CodeWhale runs it in the background with a listening status, -reads stdout, trims surrounding whitespace, and inserts the transcript into the -composer at the cursor. -Open it from the command palette with `Ctrl+K`, then search `Voice input`. - -```toml -voice_input_command = "codewhale-voice" -voice_input_timeout_secs = 60 -``` - -The command must: - -- exit `0` on success -- write only the final transcript to stdout -- write diagnostics to stderr -- avoid putting API keys directly in the command string; read secrets from the - environment or OS key store instead - -Platform helper patterns: - -- macOS: use a small helper around a local STT tool or Apple's Speech framework, - then set `voice_input_command = "codewhale-voice"`. Apple's framework supports - live and recorded speech recognition, but microphone and speech permissions - belong in the helper, not the terminal UI. -- Windows: use a PowerShell, .NET, or WinRT helper around - `Windows.Media.SpeechRecognition`. Prefer forward slashes in configured paths, - for example - `voice_input_command = "powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:/Users/me/bin/codewhale-voice.ps1"`. -- HarmonyOS/Huawei devices: use a native, ArkTS/Java, or device-bridge helper - that calls the platform/Huawei ASR capability and prints UTF-8 transcript text. - This keeps the Rust TUI portable while letting the HarmonyOS side own device - permissions and SDK packaging. - -Useful native references for helper authors: - -- Apple Speech framework: -- Windows speech recognition APIs: - -- Huawei ML Kit ASR codelab: - - Localization scope is tracked in [LOCALIZATION.md](LOCALIZATION.md). The v0.7.6 core pack covers high-visibility TUI chrome only; provider/tool schemas, personality prompts, and full documentation remain English unless explicitly @@ -476,10 +429,10 @@ If you are upgrading from older releases: ### Core keys (used by the TUI/engine) -- `provider` (string, optional): `codewhale` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `sglang`, `vllm`, or `ollama`. Legacy `deepseek-cn` configs are still accepted as an alias for `codewhale`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`. +- `provider` (string, optional): `codewhale` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `moonshot`, `sglang`, `vllm`, or `ollama`. Legacy `deepseek-cn` configs are still accepted as an alias for `codewhale`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `moonshot` targets `https://api.moonshot.ai/v1` by default, with Kimi CLI OAuth mode using `https://api.kimi.com/coding/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`. - `api_key` (string, required for hosted providers): must be non-empty for DeepSeek/hosted providers (or set the provider API key env var). Self-hosted SGLang, vLLM, and Ollama can omit it. -- `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs, `https://api.openai.com/v1` for `provider = "openai"`, `https://api.atlascloud.ai/v1` for `provider = "atlascloud"`, `https://maas-openapi.wanjiedata.com/api/v1` for `provider = "wanjie-ark"`, or the provider-specific endpoint for hosted/self-hosted providers. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. -- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `gpt-4.1` for generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `codewhale-coder:1.3b` for Ollama. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. Generic `openai`, `atlascloud`, `wanjie-ark`, and Ollama model IDs are passed through unchanged. OpenRouter provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `DEEPSEEK_MODEL` overrides this for a single process. +- `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs, `https://api.openai.com/v1` for `provider = "openai"`, `https://api.atlascloud.ai/v1` for `provider = "atlascloud"`, `https://maas-openapi.wanjiedata.com/api/v1` for `provider = "wanjie-ark"`, `https://api.moonshot.ai/v1` for `provider = "moonshot"` API-key mode, or the provider-specific endpoint for hosted/self-hosted providers. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. +- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `kimi-k2.6` for Moonshot/Kimi API-key mode, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `codewhale-coder:1.3b` for Ollama. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. Generic `openai`, `atlascloud`, `wanjie-ark`, and Ollama model IDs are passed through unchanged. OpenRouter provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `DEEPSEEK_MODEL` overrides this for a single process. - `reasoning_effort` (string, optional): `off`, `low`, `medium`, `high`, or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`. - `allow_shell` (bool, optional): defaults to `true` (sandboxed). - `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases. diff --git a/npm/codewhale/package.json b/npm/codewhale/package.json index 3f6c3cb2..c8a402f8 100644 --- a/npm/codewhale/package.json +++ b/npm/codewhale/package.json @@ -1,7 +1,7 @@ { "name": "codewhale", - "version": "0.8.44", - "codewhaleBinaryVersion": "0.8.44", + "version": "0.8.45", + "codewhaleBinaryVersion": "0.8.45", "description": "Install and run CodeWhale, the agentic terminal for open-source and open-weight coding models, from GitHub release artifacts.", "author": "Hmbown", "license": "MIT", diff --git a/npm/deepseek-tui/package.json b/npm/deepseek-tui/package.json index bbca3bb6..b99f9a15 100644 --- a/npm/deepseek-tui/package.json +++ b/npm/deepseek-tui/package.json @@ -1,6 +1,6 @@ { "name": "deepseek-tui", - "version": "0.8.44", + "version": "0.8.45", "description": "Legacy compatibility package. Renamed to `codewhale`; run `npm install -g codewhale` for new installs.", "author": "Hmbown", "license": "MIT", diff --git a/web/app/[locale]/faq/page.tsx b/web/app/[locale]/faq/page.tsx index 4a3af70c..f216552a 100644 --- a/web/app/[locale]/faq/page.tsx +++ b/web/app/[locale]/faq/page.tsx @@ -196,11 +196,11 @@ default_text_model = "openrouter/deepseek/deepseek-v4-pro"`} sources: ["README.md", "#1207"], }, { - q: "What is Goal mode? Is it available?", + q: "What does /goal do?", a: ( <> - Goal mode is a future workflow/tab direction for long-running, multi-step objectives — not the current /goal command. - The current /goal is a simple goal-setter. The full Goal mode (autonomous multi-turn task execution with checkpoint/resume) is planned but not yet implemented. + /goal is a simple goal-setter for the current session. + It does not add another app mode; the mode switcher remains Plan, Agent, and YOLO. Track progress in #891. ), From 70a655cddb731912ef01e24954967f85753cbc08 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 25 May 2026 18:51:05 -0500 Subject: [PATCH 014/283] test: use open model sentinels --- crates/tui/src/client.rs | 12 ++++++++---- crates/tui/src/client/chat.rs | 10 +++++----- crates/tui/src/config.rs | 2 +- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 570b2762..21552a79 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -1289,7 +1289,7 @@ mod tests { // and DOES replay reasoning_content — see // `deepseek_model_on_openai_provider_still_replays_reasoning_content`. let request = MessageRequest { - model: "gpt-4o".to_string(), + model: "qwen3-coder".to_string(), messages: vec![Message { role: "assistant".to_string(), content: vec![ @@ -2748,7 +2748,7 @@ mod tests { // DeepSeek reasoning model on the openai provider still gets sanitized // (see chat.rs `deepseek_model_on_openai_provider_still_replays_*`). let mut body = json!({ - "model": "gpt-4o", + "model": "qwen3-coder", "messages": [ { "role": "user", "content": "hi" }, { @@ -2759,8 +2759,12 @@ mod tests { ] }); - let result = - sanitize_thinking_mode_messages(&mut body, "gpt-4o", Some("max"), ApiProvider::Openai); + let result = sanitize_thinking_mode_messages( + &mut body, + "qwen3-coder", + Some("max"), + ApiProvider::Openai, + ); assert!(result.is_none()); let assistant = body["messages"] diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 1b691110..dd6c37a4 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -3093,7 +3093,7 @@ mod alias_thinking_detection_tests { // `reasoning_content` on providers that reject the field. assert!(!requires_reasoning_content("deepseek-v3")); assert!(!requires_reasoning_content("deepseek-coder")); - assert!(!requires_reasoning_content("gpt-4o")); + assert!(!requires_reasoning_content("qwen3-coder")); assert!(!requires_reasoning_content("claude-sonnet-4-6")); } @@ -3169,7 +3169,7 @@ mod alias_thinking_detection_tests { // openai provider must continue to have reasoning_content stripped. assert!(!should_replay_reasoning_content_for_provider( ApiProvider::Openai, - "gpt-4o", + "qwen3-coder", None, )); assert!(!should_replay_reasoning_content_for_provider( @@ -3211,7 +3211,7 @@ mod alias_thinking_detection_tests { // parser keeps inlining any `reasoning_content` it emits as text. assert!(!is_reasoning_model_for_stream( ApiProvider::Openai, - "gpt-4o" + "qwen3-coder" )); assert!(!is_reasoning_model_for_stream( ApiProvider::Openai, @@ -3220,7 +3220,7 @@ mod alias_thinking_detection_tests { // Non-DeepSeek model on a reasoning-aware provider is also unchanged. assert!(!is_reasoning_model_for_stream( ApiProvider::Deepseek, - "gpt-4o" + "qwen3-coder" )); } @@ -3230,7 +3230,7 @@ mod alias_thinking_detection_tests { // model identity, or stream parsing and message sanitisation disagree // about where reasoning tokens live. Effort=None isolates the // model/provider dimension shared by both. - for model in ["deepseek-v4-pro", "deepseek-reasoner", "gpt-4o"] { + for model in ["deepseek-v4-pro", "deepseek-reasoner", "qwen3-coder"] { for provider in [ApiProvider::Openai, ApiProvider::Deepseek] { assert_eq!( is_reasoning_model_for_stream(provider, model), diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 7e93e9d7..ce15b29e 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -5243,7 +5243,7 @@ api_key = "old-openrouter-key" #[test] fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() { - assert!(normalize_model_name("gpt-4o").is_none()); + assert!(normalize_model_name("qwen3-coder").is_none()); assert!(normalize_model_name("codewhale v4").is_none()); assert!(normalize_model_name("").is_none()); } From af1f99e85d67585ca83cea558420afca8623b5e1 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 25 May 2026 22:06:39 -0500 Subject: [PATCH 015/283] docs(changelog): add missing v0.8.45 entries --- CHANGELOG.md | 10 ++++++++++ crates/tui/CHANGELOG.md | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47c20bd9..7f21e957 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **RLM session objects.** `rlm_open` can now load `session://` refs, exposing the active prompt, history, and session data as symbolic objects inside RLM REPLs (#2047). +- **Command palette voice input.** The command palette can launch a configured + speech-to-text helper and show footer status while transcription runs + (#2047). +- **Moonshot/Kimi OAuth provider.** Moonshot/Kimi is now a first-class + provider, including Kimi CLI OAuth reuse, secure refresh writes, model + completion, CLI auth, and secret-store integration. - **Deterministic whale-species sub-agent names.** Sub-agents now get stable, human-readable whale-species nicknames (e.g. "Beluga", "Orca") while preserving the raw agent ID in the popup (#2035, #2016). @@ -52,6 +58,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Thanks @reidliu41 (#2143). - **Model picker selection survives Esc.** Dismissing the model picker with Esc no longer loses the highlighted selection. Thanks @reidliu41 (#2056). +- **Moonshot/Kimi sessions launch from the dispatcher.** The `codewhale` + wrapper now includes Moonshot/Kimi in the TUI provider allowlist, so + `codewhale --provider moonshot --model kimi-k2.6` reaches the TUI instead of + stopping after config resolution. - **Slash recovery no longer restores command tails in the composer.** Resuming a session or recovering from a crash no longer leaves stale slash-command text (e.g. `/sessions`) in the composer input (#2047, #2032). diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 47c20bd9..7f21e957 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -14,6 +14,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **RLM session objects.** `rlm_open` can now load `session://` refs, exposing the active prompt, history, and session data as symbolic objects inside RLM REPLs (#2047). +- **Command palette voice input.** The command palette can launch a configured + speech-to-text helper and show footer status while transcription runs + (#2047). +- **Moonshot/Kimi OAuth provider.** Moonshot/Kimi is now a first-class + provider, including Kimi CLI OAuth reuse, secure refresh writes, model + completion, CLI auth, and secret-store integration. - **Deterministic whale-species sub-agent names.** Sub-agents now get stable, human-readable whale-species nicknames (e.g. "Beluga", "Orca") while preserving the raw agent ID in the popup (#2035, #2016). @@ -52,6 +58,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Thanks @reidliu41 (#2143). - **Model picker selection survives Esc.** Dismissing the model picker with Esc no longer loses the highlighted selection. Thanks @reidliu41 (#2056). +- **Moonshot/Kimi sessions launch from the dispatcher.** The `codewhale` + wrapper now includes Moonshot/Kimi in the TUI provider allowlist, so + `codewhale --provider moonshot --model kimi-k2.6` reaches the TUI instead of + stopping after config resolution. - **Slash recovery no longer restores command tails in the composer.** Resuming a session or recovering from a crash no longer leaves stale slash-command text (e.g. `/sessions`) in the composer input (#2047, #2032). From 05e4b08335c87a27e20dc02b0ece4c799a80cbb9 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 25 May 2026 22:06:44 -0500 Subject: [PATCH 016/283] fix(cli): allow Moonshot Kimi TUI delegation --- crates/cli/src/lib.rs | 82 ++++++++++++++++++++++++++++++++++----- crates/secrets/src/lib.rs | 19 +++++++-- 2 files changed, 89 insertions(+), 12 deletions(-) diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 2bd75181..18cc93d8 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -1453,12 +1453,13 @@ fn build_tui_command( | ProviderKind::Openrouter | ProviderKind::Novita | ProviderKind::Fireworks + | ProviderKind::Moonshot | ProviderKind::Sglang | ProviderKind::Vllm | ProviderKind::Ollama ) { bail!( - "The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `codewhale model ...` for provider registry inspection.", + "The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, OpenRouter, Novita, Fireworks, Moonshot/Kimi, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `codewhale model ...` for provider registry inspection.", resolved_runtime.provider.as_str() ); } @@ -1480,14 +1481,10 @@ fn build_tui_command( } if let Some(api_key) = resolved_runtime.api_key.as_ref() { cmd.env("DEEPSEEK_API_KEY", api_key); - if resolved_runtime.provider == ProviderKind::Openai { - cmd.env("OPENAI_API_KEY", api_key); - } - if resolved_runtime.provider == ProviderKind::Atlascloud { - cmd.env("ATLASCLOUD_API_KEY", api_key); - } - if resolved_runtime.provider == ProviderKind::WanjieArk { - cmd.env("WANJIE_ARK_API_KEY", api_key); + for var in provider_env_vars(resolved_runtime.provider) { + if *var != "DEEPSEEK_API_KEY" { + cmd.env(var, api_key); + } } let source = resolved_runtime .api_key_source @@ -2668,6 +2665,73 @@ mod tests { ); } + #[test] + fn build_tui_command_allows_moonshot_and_forwards_kimi_key() { + let _lock = env_lock(); + let dir = tempfile::TempDir::new().expect("tempdir"); + let custom = dir + .path() + .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX)); + std::fs::write(&custom, b"").unwrap(); + let custom_str = custom.to_string_lossy().into_owned(); + let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str); + + let cli = parse_ok(&[ + "codewhale", + "--provider", + "moonshot", + "--model", + "kimi-k2.6", + "--workspace", + "/tmp/codewhale-workspace", + ]); + let resolved = ResolvedRuntimeOptions { + provider: ProviderKind::Moonshot, + model: "kimi-k2.6".to_string(), + api_key: Some("resolved-kimi-key".to_string()), + api_key_source: Some(RuntimeApiKeySource::Env), + base_url: "https://api.moonshot.ai/v1".to_string(), + auth_mode: Some("api_key".to_string()), + output_mode: None, + log_level: None, + telemetry: false, + approval_policy: None, + sandbox_mode: None, + yolo: None, + http_headers: std::collections::BTreeMap::new(), + }; + + let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command"); + assert_eq!( + command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(), + Some("moonshot") + ); + assert_eq!( + command_env(&cmd, "DEEPSEEK_MODEL").as_deref(), + Some("kimi-k2.6") + ); + assert_eq!( + command_env(&cmd, "DEEPSEEK_BASE_URL").as_deref(), + Some("https://api.moonshot.ai/v1") + ); + assert_eq!( + command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(), + Some("resolved-kimi-key") + ); + assert_eq!( + command_env(&cmd, "MOONSHOT_API_KEY").as_deref(), + Some("resolved-kimi-key") + ); + assert_eq!( + command_env(&cmd, "KIMI_API_KEY").as_deref(), + Some("resolved-kimi-key") + ); + assert_eq!( + command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(), + Some("env") + ); + } + #[test] fn parses_top_level_prompt_flag_for_canonical_one_shot() { let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]); diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs index 0254aa61..69c3fc9a 100644 --- a/crates/secrets/src/lib.rs +++ b/crates/secrets/src/lib.rs @@ -484,9 +484,7 @@ impl Secrets { /// Resolve a secret with `secret store → env → none` precedence. /// - /// `name` is the canonical provider name (`"deepseek"`, - /// `"openrouter"`, `"novita"`, `"nvidia"`/`"nvidia-nim"`, `"openai"`, - /// or `"atlascloud"`). + /// `name` is the canonical provider name or a supported provider alias. /// Empty strings on either layer are treated as "not set". #[must_use] pub fn resolve(&self, name: &str) -> Option { @@ -779,6 +777,21 @@ mod tests { unsafe { std::env::remove_var("FIREWORKS_API_KEY") }; } + #[test] + fn moonshot_kimi_env_aliases_resolve() { + let _lock = env_lock(); + clear_known_envs(); + // Safety: env mutation guarded by env_lock(). + unsafe { std::env::set_var("KIMI_API_KEY", "kimi-key") }; + + assert_eq!(env_for("moonshot").as_deref(), Some("kimi-key")); + assert_eq!(env_for("moonshot-ai").as_deref(), Some("kimi-key")); + assert_eq!(env_for("kimi").as_deref(), Some("kimi-key")); + assert_eq!(env_for("kimi-k2").as_deref(), Some("kimi-key")); + // Safety: env mutation guarded by env_lock(). + unsafe { std::env::remove_var("KIMI_API_KEY") }; + } + #[test] fn sglang_env_aliases_resolve() { let _lock = env_lock(); From 85e9a46ddfb8e507219906d6a64e1c981106114b Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 25 May 2026 22:06:51 -0500 Subject: [PATCH 017/283] docs(readme): refresh provider list and Windows surface --- README.md | 72 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 55 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 411cf0c9..d1dd66a1 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ It is built around DeepSeek V4 (`deepseek-v4-pro` / `deepseek-v4-flash`), includ - **Reasoning-effort tiers** — cycle through `off → high → max` with `Shift + Tab` - **Session save/resume/fork** — checkpoint long-running sessions and fork saved conversations into sibling paths with parent lineage shown in the picker - **Workspace rollback** — side-git pre/post-turn snapshots with `/restore` and `revert_turn`, without touching your repo's `.git` -- **OS-level sandbox** — Seatbelt on macOS, Landlock on Linux, Job Objects on Windows; shell commands run with workspace-scoped filesystem access only +- **Approval + platform sandbox controls** — Seatbelt on macOS and Landlock on Linux where available; Windows uses the same approval flow and terminal/runtime protections while OS-level filesystem isolation remains a tracked helper contract - **Durable task queue** — background tasks can survive restarts - **HTTP/SSE runtime API** — `codewhale serve --http` for headless agent workflows - **MCP protocol** — connect to Model Context Protocol servers for extended tooling; please see [docs/MCP.md](docs/MCP.md) @@ -210,12 +210,37 @@ codewhale --version Prebuilt binaries can also be downloaded from [GitHub Releases](https://github.com/Hmbown/CodeWhale/releases). Use `DEEPSEEK_TUI_RELEASE_BASE_URL` for mirrored release assets. -### Windows (Scoop) +### Windows -[Scoop](https://scoop.sh) is a Windows package manager. The `codewhale` package is listed -in Scoop's main bucket, but that manifest updates independently and can lag the -GitHub/npm/Cargo release. Run `scoop update` first, then verify the installed -version with `codewhale --version`: +Windows x64 is a first-class release target. Use npm or direct GitHub release +downloads when you need the newest v0.8.45 binary; Cargo also works when Rust +1.88+ and the MSVC toolchain are installed. + +```powershell +npm install -g codewhale +codewhale --version + +cargo install codewhale-cli --locked --force +cargo install codewhale-tui --locked --force +``` + +Current Windows terminal behavior: + +- interactive sessions always use the TUI-owned alternate screen; the old + `--no-alt-screen` flag is accepted for script compatibility but no longer + disables the interactive alternate screen +- runtime logs stay out of the alternate-screen buffer when `RUST_LOG` or + `DEEPSEEK_LOG_LEVEL` is enabled +- mouse capture defaults on in Windows Terminal, ConEmu, and Cmder, but stays + off in legacy console hosts and JetBrains terminals; use `--mouse-capture` or + `--no-mouse-capture` to override +- mouse-wheel-as-arrow terminals keep composer history usable by routing empty + composer Up/Down to transcript scrolling where appropriate + +[Scoop](https://scoop.sh) is also supported. The `deepseek-tui` package is +listed in Scoop's main bucket, but that manifest updates independently and can +lag the GitHub/npm/Cargo release. Run `scoop update` first, then verify the +installed version with `codewhale --version`: ```bash scoop update @@ -248,17 +273,27 @@ Both binaries are required. Cross-compilation and platform-specific notes: [docs -### Other API Providers +### Providers -Official DeepSeek remains the default and first-class path. Other providers are -additive, with OpenRouter starting from DeepSeek Pro/Flash before broader -open-model catalogs are enabled. +Official DeepSeek remains the default and first-class path. v0.8.45 supports +all 12 provider IDs in this order: `deepseek`, `nvidia-nim`, `openai`, +`atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `moonshot`, +`sglang`, `vllm`, and `ollama`. Other providers are additive, with OpenRouter +starting from DeepSeek Pro/Flash before broader open-model catalogs are enabled. ```bash +# DeepSeek (default) +codewhale auth set --provider deepseek --api-key "YOUR_DEEPSEEK_API_KEY" +codewhale --provider deepseek --model deepseek-v4-pro + # NVIDIA NIM codewhale auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY" codewhale --provider nvidia-nim +# Generic OpenAI-compatible endpoint +codewhale auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY" +OPENAI_BASE_URL="https://openai-compatible.example/v4" codewhale --provider openai --model deepseek-v4-pro + # AtlasCloud codewhale auth set --provider atlascloud --api-key "YOUR_ATLASCLOUD_API_KEY" codewhale --provider atlascloud @@ -283,9 +318,11 @@ codewhale --provider fireworks --model deepseek-v4-pro codewhale auth set --provider moonshot --api-key "YOUR_MOONSHOT_OR_KIMI_API_KEY" codewhale --provider moonshot --model kimi-k2.6 -# Generic OpenAI-compatible endpoint -codewhale auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY" -OPENAI_BASE_URL="https://openai-compatible.example/v4" codewhale --provider openai --model deepseek-v4-pro +# Moonshot/Kimi with Kimi CLI OAuth +kimi login +mkdir -p ~/.deepseek +printf 'provider = "moonshot"\n\n[providers.moonshot]\nauth_mode = "kimi_oauth"\n' >> ~/.deepseek/config.toml +codewhale --provider moonshot --model kimi-for-coding # Self-hosted SGLang SGLANG_BASE_URL="http://localhost:30000/v1" codewhale --provider sglang --model deepseek-v4-flash @@ -466,15 +503,16 @@ Key environment variables: | `DEEPSEEK_HTTP_HEADERS` | Optional custom model request headers, e.g. `X-Model-Provider-Id=your-model-provider` | | `DEEPSEEK_MODEL` | Default model | | `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | Stream idle timeout in seconds, default `300`, clamped to `1..=3600` | -| `DEEPSEEK_PROVIDER` | `codewhale` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `moonshot`, `sglang`, `vllm`, `ollama` | +| `DEEPSEEK_PROVIDER` | `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `moonshot`, `sglang`, `vllm`, `ollama` | | `DEEPSEEK_PROFILE` | Config profile name | | `DEEPSEEK_MEMORY` | Set to `on` to enable user memory | | `DEEPSEEK_ALLOW_INSECURE_HTTP=1` | Allow non-local `http://` API base URLs on trusted networks | -| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `MOONSHOT_API_KEY` / `KIMI_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | Provider auth | +| `NVIDIA_API_KEY` / `NVIDIA_NIM_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `WANJIE_API_KEY` / `WANJIE_MAAS_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `MOONSHOT_API_KEY` / `KIMI_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | Provider auth | +| `NVIDIA_NIM_BASE_URL` / `NIM_BASE_URL` / `NVIDIA_BASE_URL` | NVIDIA NIM endpoint override | | `OPENAI_BASE_URL` / `OPENAI_MODEL` | Generic OpenAI-compatible endpoint and model ID | | `ATLASCLOUD_BASE_URL` / `ATLASCLOUD_MODEL` | AtlasCloud endpoint and model override | -| `WANJIE_ARK_BASE_URL` / `WANJIE_ARK_MODEL` | Wanjie Ark endpoint and model override | -| `MOONSHOT_BASE_URL` / `KIMI_BASE_URL` / `MOONSHOT_MODEL` / `KIMI_MODEL` | Moonshot/Kimi endpoint and model override | +| `WANJIE_ARK_BASE_URL` / `WANJIE_BASE_URL` / `WANJIE_MAAS_BASE_URL` / `WANJIE_ARK_MODEL` / `WANJIE_MODEL` / `WANJIE_MAAS_MODEL` | Wanjie Ark endpoint and model override | +| `MOONSHOT_BASE_URL` / `KIMI_BASE_URL` / `MOONSHOT_MODEL` / `KIMI_MODEL_NAME` / `KIMI_MODEL` | Moonshot/Kimi endpoint and model override | | `OPENROUTER_BASE_URL` | OpenRouter endpoint override | | `NOVITA_BASE_URL` | Novita endpoint override | | `FIREWORKS_BASE_URL` | Fireworks endpoint override | From 64c870f35c7a6f56cbe273935eb55848e48801bb Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 25 May 2026 22:07:10 -0500 Subject: [PATCH 018/283] docs(web): sync v0.8.45 facts and provider surface --- web/app/[locale]/docs/page.tsx | 46 +++++++++++++++---------------- web/app/[locale]/faq/page.tsx | 22 ++++++++------- web/app/[locale]/page.tsx | 22 +++++++-------- web/app/[locale]/roadmap/page.tsx | 26 ++++++++--------- web/lib/facts-drift.ts | 14 ++++++---- web/lib/facts.generated.ts | 34 ++++++++++++++++------- web/lib/github.ts | 2 +- web/lib/roadmap-feed.ts | 27 +++++++++++++++--- web/scripts/derive-facts.mjs | 17 +++++++----- 9 files changed, 125 insertions(+), 85 deletions(-) diff --git a/web/app/[locale]/docs/page.tsx b/web/app/[locale]/docs/page.tsx index cfe384b5..78be0f71 100644 --- a/web/app/[locale]/docs/page.tsx +++ b/web/app/[locale]/docs/page.tsx @@ -121,7 +121,7 @@ export default async function DocsPage({ params }: { params: Promise<{ locale: s { group: "Git / 诊断 / 测试", tools: "git_status · git_diff · diagnostics · run_tests" }, { group: "子 Agent", tools: "agent_open · agent_eval · agent_close —— 持久会话,并行执行,通过 var_handle 读取大结果" }, { group: "递归 LM (RLM)", tools: "rlm_open · rlm_eval · rlm_configure · rlm_close —— 沙箱 Python REPL,内置 peek/search/chunk/sub_query_batch 等辅助函数" }, - { group: "MCP", tools: "mcp__——从 ~/.codewhale/mcp.json 自动注册" }, + { group: "MCP", tools: "mcp__——从 ~/.deepseek/mcp.json 自动注册" }, ].map((row) => (
{row.group}
@@ -152,8 +152,8 @@ export default async function DocsPage({ params }: { params: Promise<{ locale: s ))}

- 沙箱:{facts.sandboxBackends.join("、")}。工作区边界默认为 --workspace。 - /trust 可解除边界限制。 + 沙箱:{facts.sandboxBackends.join("、")}。Windows 当前不宣称 OS 级文件系统沙箱,但保留同样的审批、工作区边界和终端运行时保护。 + /trust 可解除工作区边界限制。

@@ -163,7 +163,7 @@ export default async function DocsPage({ params }: { params: Promise<{ locale: s 配置 Configuration
-{`# ~/.codewhale/config.toml
+{`# ~/.deepseek/config.toml
 api_key = "sk-..."
 base_url = "https://api.deepseek.com"
 default_text_model = "${facts.defaultModel ?? "deepseek-v4-pro"}"  # 默认模型;deepseek-v4-flash 用于快速 / 子智能体
@@ -179,7 +179,7 @@ default_timeout_secs = 30
 
 [[hooks.hooks]]
 event = "session_start"                     # 也支持: tool_call_before / tool_call_after
-command = "~/.codewhale/hooks/pre.sh"        # / message_submit / mode_change / on_error / shell_env`}
+command = "~/.deepseek/hooks/pre.sh"         # / message_submit / mode_change / on_error / shell_env`}
                 

完整参考:config.example.toml。 @@ -193,7 +193,7 @@ command = "~/.codewhale/hooks/pre.sh" # / message_submit / mode_change /

codewhale 双向支持模型上下文协议(Model Context Protocol):作为客户端从 - ~/.codewhale/mcp.json 加载服务器,同时也可作为服务器暴露工具 + ~/.deepseek/mcp.json 加载服务器,同时也可作为服务器暴露工具 (codewhale mcp)。工具以 mcp_<server>_<tool> 形式呈现。

@@ -218,7 +218,7 @@ command = "~/.codewhale/hooks/pre.sh"        # / message_submit / mode_change /
                   技能 Skills
                 
                 

- 技能是 ~/.codewhale/skills/<name>/ 下的一个文件夹, + 技能是 ~/.deepseek/skills/<name>/ 下的一个文件夹, 根目录包含 SKILL.md。Agent 启动时加载技能名称和描述, 在需要时通过 Skill 工具拉取完整内容。

@@ -253,7 +253,7 @@ command = "~/.codewhale/hooks/pre.sh" # / message_submit / mode_change /

使用 codewhale auth set --provider <id> 切换。下表为 crates/tui/src/config.rsApiProvider 枚举的实时投影 - ,目前共 {facts.providers.length} 个。 + ,v0.8.45 当前共 {facts.providers.length} 个。

{facts.providers.map((p) => ( @@ -265,9 +265,8 @@ command = "~/.codewhale/hooks/pre.sh" # / message_submit / mode_change / ))}

- 开放模型平台方向:CodeWhale 正在扩展对 - OpenRouter Hugging Face 自托管 模型的支持, - 为您提供完全自主的模型选择——从云端 API 到本地部署均可覆盖。 + 开放模型平台方向:CodeWhale 保持 DeepSeek 优先,同时内置 Moonshot/Kimi、OpenRouter、NVIDIA NIM、 + AtlasCloud、Wanjie Ark、Novita、Fireworks 和自托管 SGLang/vLLM/Ollama 路径。

@@ -373,7 +372,7 @@ command = "~/.codewhale/hooks/pre.sh" # / message_submit / mode_change / { group: "Git / diag / test", tools: "git_status · git_diff · diagnostics · run_tests" }, { group: "Sub-agents", tools: "agent_open · agent_eval · agent_close — persistent sessions, parallel execution, bounded result retrieval via var_handle" }, { group: "Recursive LM (RLM)", tools: "rlm_open · rlm_eval · rlm_configure · rlm_close — sandboxed Python REPL with peek/search/chunk/sub_query_batch helpers" }, - { group: "MCP", tools: "mcp__ — auto-registered from ~/.codewhale/mcp.json" }, + { group: "MCP", tools: "mcp__ — auto-registered from ~/.deepseek/mcp.json" }, ].map((row) => (
{row.group}
@@ -403,8 +402,9 @@ command = "~/.codewhale/hooks/pre.sh" # / message_submit / mode_change / ))}

- Sandbox: {facts.sandboxBackends.join(", ")}. Workspace boundary defaults to{" "} - --workspace. /trust lifts the boundary. + Sandbox: {facts.sandboxBackends.join(", ")}. On Windows, CodeWhale does not advertise + OS-level filesystem isolation yet, but keeps the same approvals, workspace boundary, + and terminal runtime protections. /trust lifts the workspace boundary.

@@ -413,7 +413,7 @@ command = "~/.codewhale/hooks/pre.sh" # / message_submit / mode_change / Configuration 配置
-{`# ~/.codewhale/config.toml
+{`# ~/.deepseek/config.toml
 api_key = "sk-..."
 base_url = "https://api.deepseek.com"
 default_text_model = "${facts.defaultModel ?? "deepseek-v4-pro"}"  # default; deepseek-v4-flash is the fast / sub-agent option
@@ -429,7 +429,7 @@ default_timeout_secs = 30
 
 [[hooks.hooks]]
 event = "session_start"                     # or: tool_call_before / tool_call_after
-command = "~/.codewhale/hooks/pre.sh"        # / message_submit / mode_change / on_error / shell_env`}
+command = "~/.deepseek/hooks/pre.sh"         # / message_submit / mode_change / on_error / shell_env`}
                 

Full reference: config.example.toml. @@ -442,7 +442,7 @@ command = "~/.codewhale/hooks/pre.sh" # / message_submit / mode_change /

codewhale speaks the Model Context Protocol both ways: as a client (loads - servers from ~/.codewhale/mcp.json) and as a server + servers from ~/.deepseek/mcp.json) and as a server (codewhale mcp). Tools surface as mcp_<server>_<tool>.

@@ -466,7 +466,7 @@ command = "~/.codewhale/hooks/pre.sh"        # / message_submit / mode_change /
                   Skills 技能
                 
                 

- A skill is a folder under ~/.codewhale/skills/<name>/ + A skill is a folder under ~/.deepseek/skills/<name>/ with a SKILL.md at the root. The agent loads skill names + descriptions on startup and can pull in the full body via the Skill tool when relevant.

@@ -500,7 +500,7 @@ command = "~/.codewhale/hooks/pre.sh" # / message_submit / mode_change /

Switch with codewhale auth set --provider <id>. The table below is a live projection of the ApiProvider enum - in crates/tui/src/config.rs — currently {facts.providers.length} providers. + in crates/tui/src/config.rs — v0.8.45 currently has {facts.providers.length} providers.

{facts.providers.map((p) => ( @@ -512,9 +512,9 @@ command = "~/.codewhale/hooks/pre.sh" # / message_submit / mode_change / ))}

- Open-model platform direction: CodeWhale is expanding support for - OpenRouter, Hugging Face, and self-hosted models, - giving you full sovereignty over model choice — from cloud APIs to local deployments. + Open-model platform direction: CodeWhale stays DeepSeek-first while shipping Moonshot/Kimi, + OpenRouter, NVIDIA NIM, AtlasCloud, Wanjie Ark, Novita, Fireworks, and self-hosted + SGLang/vLLM/Ollama paths.

@@ -547,4 +547,4 @@ command = "~/.codewhale/hooks/pre.sh" # / message_submit / mode_change / )} ); -} \ No newline at end of file +} diff --git a/web/app/[locale]/faq/page.tsx b/web/app/[locale]/faq/page.tsx index f216552a..ec5f7dcd 100644 --- a/web/app/[locale]/faq/page.tsx +++ b/web/app/[locale]/faq/page.tsx @@ -23,7 +23,7 @@ const faqEn: FaqItem[] = [ q: "What is CodeWhale?", a: ( <> - CodeWhale is a terminal-native coding agent for open-source and open-weight models. It runs from the codewhale command, streams reasoning blocks, edits local workspaces with approval gates, and can auto-route each turn to the right model and thinking level. DeepSeek V4 is the first-class model path; OpenRouter is ready. Hugging Face, self-hosted, and other open-model surfaces are on the roadmap. + CodeWhale is a terminal-native coding agent for open-source and open-weight models. It runs from the codewhale command, streams reasoning blocks, edits local workspaces with approval gates, and can auto-route each turn to the right model and thinking level. DeepSeek V4 is the first-class model path; v0.8.45 also ships Moonshot/Kimi, OpenRouter, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, Novita, Fireworks, SGLang, vLLM, and Ollama paths. ), sources: ["README.md", "docs/ARCHITECTURE.md"], @@ -112,12 +112,13 @@ codewhale doctor # full connectivity check`}

CodeWhale ships with these built-in providers:

  • DeepSeek — first-class, native API. Reasoning streaming, cache metrics, thinking effort control.
  • +
  • Moonshot/Kimi — Kimi API key mode or local Kimi CLI OAuth reuse.
  • OpenRouter — unified API for DeepSeek models and more.
  • -
  • OpenAI, NVIDIA NIM, Novita, Fireworks, sglang, vLLM, Ollama
  • +
  • OpenAI-compatible, NVIDIA NIM, AtlasCloud, Wanjie Ark, Novita, Fireworks, SGLang, vLLM, Ollama

Set the corresponding env var (e.g. OPENROUTER_API_KEY) and your provider in ~/.deepseek/config.toml. - Hugging Face, ZenMux, and self-hosted OpenAI-compatible endpoints are on the roadmap. + SGLang, vLLM, and Ollama can also run against self-hosted OpenAI-compatible endpoints.

), @@ -156,7 +157,7 @@ default_text_model = "openrouter/deepseek/deepseek-v4-pro"`} Yes. Use the vllm, sglang, or ollama providers with your local endpoint. For OpenAI-compatible endpoints (llama.cpp server, text-generation-webui, Aphrodite, etc.), you can use the openai provider with a custom base_url. CodeWhale also respects DEEPSEEK_ALLOW_INSECURE_HTTP=true for local HTTP endpoints. - Full Hugging Face TGI/vLLM integration is on the roadmap. + Direct Hugging Face TGI discovery remains roadmap work. ), sources: ["#574", "#1303", "docs/CONFIGURATION.md"], @@ -211,7 +212,7 @@ default_text_model = "openrouter/deepseek/deepseek-v4-pro"`} a: ( <> CodeWhale runs entirely on your machine. No telemetry, no cloud processing of your code. - Sandbox backends: seatbelt (macOS), landlock (Linux), restricted tokens (Windows). + Sandbox backends: seatbelt (macOS), landlock (Linux). Windows keeps the same approval and terminal runtime protections, but does not advertise OS-level filesystem isolation yet. Workspace boundaries default to --workspace. /trust lifts them. Approval mode is configurable per session. All credential/approval/elevation events are written to ~/.deepseek/audit.log. @@ -336,7 +337,7 @@ const faqZh: FaqItem[] = [ q: "CodeWhale 是什么?", a: ( <> - CodeWhale 是一个面向开源模型的终端原生编程智能体。通过 codewhale 命令启动,流式输出推理块,在有审批门槛的情况下编辑本地工作区,并可为每个回合自动选择最合适的模型和推理深度。DeepSeek V4 是一级模型路径;OpenRouter 已就绪。Hugging Face、自托管等开放模型接口已在路线图中。 + CodeWhale 是一个面向开源模型的终端原生编程智能体。通过 codewhale 命令启动,流式输出推理块,在有审批门槛的情况下编辑本地工作区,并可为每个回合自动选择最合适的模型和推理深度。DeepSeek V4 是一级模型路径;v0.8.45 也内置 Moonshot/Kimi、OpenRouter、NVIDIA NIM、OpenAI 兼容、AtlasCloud、Wanjie Ark、Novita、Fireworks、SGLang、vLLM 和 Ollama 路径。 ), sources: ["README.md", "docs/ARCHITECTURE.md"], @@ -424,12 +425,13 @@ codewhale doctor # 完整连接检查`}

CodeWhale 内建以下提供商:

  • DeepSeek — 一级支持,原生 API。推理流、缓存指标、思考力度控制。
  • +
  • Moonshot/Kimi — Kimi API key 模式或复用本地 Kimi CLI OAuth。
  • OpenRouter — 统一 API,可访问 DeepSeek 等模型。
  • -
  • OpenAINVIDIA NIMNovitaFireworkssglangvLLMOllama
  • +
  • OpenAI 兼容NVIDIA NIMAtlasCloudWanjie ArkNovitaFireworksSGLangvLLMOllama

设置对应的环境变量(如 OPENROUTER_API_KEY)并在 ~/.deepseek/config.toml 中配置你的提供商。 - Hugging Face、ZenMux 和自托管 OpenAI 兼容端点正在路线图中。 + SGLang、vLLM 和 Ollama 也可以连接自托管 OpenAI 兼容端点。

), @@ -468,7 +470,7 @@ default_text_model = "openrouter/deepseek/deepseek-v4-pro"`} 可以。使用 vllmsglangollama 提供商连接本地端点。 对于 OpenAI 兼容端点(llama.cpp server、text-generation-webui 等),可以使用 openai 提供商并设置自定义 base_url。 CodeWhale 也支持 DEEPSEEK_ALLOW_INSECURE_HTTP=true 用于本地 HTTP 端点。 - 完整的 Hugging Face TGI/vLLM 集成正在路线图中。 + Hugging Face TGI 的直接发现仍在路线图中。 ), sources: ["#574", "#1303", "docs/CONFIGURATION.md"], @@ -523,7 +525,7 @@ default_text_model = "openrouter/deepseek/deepseek-v4-pro"`} a: ( <> CodeWhale 完全在你的机器上运行。无遥测,不会将你的代码上传到云端处理。 - 沙箱后端:seatbelt(macOS)、landlock(Linux)、受限令牌(Windows)。 + 沙箱后端:seatbelt(macOS)、landlock(Linux)。Windows 保留同样的审批与终端运行时保护,但当前不宣称 OS 级文件系统沙箱。 工作区边界默认为 --workspace/trust 可解除边界。 审批模式可按会话配置。所有凭证/审批/提权事件写入 ~/.deepseek/audit.log。 diff --git a/web/app/[locale]/page.tsx b/web/app/[locale]/page.tsx index 05762720..2c999f39 100644 --- a/web/app/[locale]/page.tsx +++ b/web/app/[locale]/page.tsx @@ -15,7 +15,7 @@ const FALLBACK_STATS: RepoStats = { forks: 0, openIssues: 0, openPulls: 0, - contributors: 98, + contributors: 99, fetchedAt: new Date().toISOString(), }; @@ -94,8 +94,8 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s

CodeWhale {isZh - ? " 是面向 DeepSeek V4 及其他开放权重模型的终端原生编程智能体。它读改文件、跑测试、调用 MCP 服务器,全程在你的文件系统沙箱内运行。" - : " is a terminal-native coding agent for DeepSeek V4 and other open-weight models. It reads and edits files, runs tests, calls MCP servers — all inside your filesystem sandbox."} + ? " 是面向 DeepSeek V4 及其他开放权重模型的终端原生编程智能体。它读改文件、跑测试、调用 MCP 服务器,并通过审批、工作区边界和平台沙箱控制风险。" + : " is a terminal-native coding agent for DeepSeek V4 and other open-weight models. It reads and edits files, runs tests, calls MCP servers, and controls risk through approvals, workspace boundaries, and platform sandboxes."}

@@ -155,7 +155,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s )}
- {isZh ? "需要 Node 或 Rust 1.88+" : "needs Node or Rust 1.88+"} + {isZh ? "Linux / macOS / Windows x64" : "Linux / macOS / Windows x64"} {isZh ? "其他方式 →" : "other ways →"}
@@ -188,14 +188,14 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
02 · 开源模型优先

DeepSeek V4 深度集成

- 原生 DeepSeek API:推理流、缓存指标、思考力度控制。OpenRouter、NVIDIA NIM、vLLM、sglang 同时可选。 + 原生 DeepSeek API:推理流、缓存指标、思考力度控制。Moonshot/Kimi、OpenRouter、NVIDIA NIM、vLLM、SGLang 等同时可选。

03 · 沙箱边界

Plan、Agent、YOLO

- Plan 只读;Agent 风险操作前确认;YOLO 全自动。沙箱:seatbelt(macOS)、landlock(Linux)、受限令牌(Windows)。 + Plan 只读;Agent 风险操作前确认;YOLO 全自动。macOS 使用 seatbelt,Linux 使用 landlock;Windows 保留同样的审批与终端保护。

@@ -212,14 +212,14 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
02 · open models first

DeepSeek V4, deeply integrated

- Native DeepSeek API: reasoning streaming, cache metrics, thinking-effort control. OpenRouter, NVIDIA NIM, vLLM, and sglang also supported. + Native DeepSeek API: reasoning streaming, cache metrics, thinking-effort control. Moonshot/Kimi, OpenRouter, NVIDIA NIM, vLLM, and SGLang are also supported.

-
03 · sandboxed
+
03 · controlled

Plan, Agent, YOLO

- Plan reads only. Agent asks before risky ops. YOLO auto-approves. Sandboxed via seatbelt (macOS), landlock (Linux), restricted tokens (Windows). + Plan reads only. Agent asks before risky ops. YOLO auto-approves. macOS uses seatbelt, Linux uses landlock; Windows keeps the same approval and terminal protections.

@@ -325,7 +325,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s B -->|tool call| T["read_file · edit_file · grep
apply_patch · exec_shell
mcp_<server>_<tool>"] T -->|approval Y/N| P["审批对话框
approval dialog"] P --> B - T -->|exec| S["沙箱
seatbelt · landlock · win32"] + T -->|exec| S["平台控制
seatbelt · landlock · approvals"] classDef accent fill:#e9eefe,stroke:#0e0e10,stroke-width:1px; classDef api fill:#0e0e10,stroke:#0e0e10,color:#ffffff; class C api; @@ -337,7 +337,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s B -->|tool call| T["read_file · edit_file · grep
apply_patch · exec_shell
mcp_<server>_<tool>"] T -->|approval Y/N| P["Approval
dialog"] P --> B - T -->|exec| S["Sandbox
seatbelt · landlock · win32"] + T -->|exec| S["Platform controls
seatbelt · landlock · approvals"] classDef accent fill:#e9eefe,stroke:#0e0e10,stroke-width:1px; classDef api fill:#0e0e10,stroke:#0e0e10,color:#ffffff; class C api; diff --git a/web/app/[locale]/roadmap/page.tsx b/web/app/[locale]/roadmap/page.tsx index 17e382bb..787ff0c6 100644 --- a/web/app/[locale]/roadmap/page.tsx +++ b/web/app/[locale]/roadmap/page.tsx @@ -26,12 +26,12 @@ const tracksEn = [ { title: "Sub-agent parallel execution", note: "agent_open / agent_eval / agent_close; up to 10 concurrent sessions with bounded result handles" }, { title: "RLM batched processing", note: "Persistent sandboxed Python REPL with 1–16 cheap parallel children for long-input analysis" }, { title: "Three operating modes", note: "Plan (read-only), Agent (default), YOLO (auto-approved); orthogonal suggest / auto / never approval" }, - { title: "Per-platform sandbox", note: "seatbelt (macOS), landlock (Linux); Windows containment via restricted tokens (limited)" }, + { title: "Per-platform controls", note: "seatbelt (macOS), landlock (Linux); Windows keeps approvals and terminal/runtime protections while OS sandbox work remains tracked" }, { title: "Durable sessions + tasks", note: "Save, resume, rollback; background task queue with replayable timelines under ~/.deepseek/tasks/" }, - { title: "Bidirectional MCP", note: "Consume tools from external servers; expose as server via `deepseek mcp`; ~/.deepseek/mcp.json" }, + { title: "Bidirectional MCP", note: "Consume tools from external servers; expose as server via `codewhale mcp`; ~/.deepseek/mcp.json" }, { title: "Skills + unified slash palette", note: "~/.deepseek/skills/ auto-loading; /help, /mode, /status, /config, /trust, /feedback" }, - { title: "OpenRouter provider", note: "First-class OpenRouter integration with 300+ models across dozens of providers" }, - { title: "Multi-provider support", note: "Hot-swap between providers (DeepSeek, OpenAI, Anthropic, OpenRouter) per session" }, + { title: "v0.8.45 provider surface", note: "DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, OpenRouter, Novita, Fireworks, Moonshot/Kimi, SGLang, vLLM, and Ollama" }, + { title: "Moonshot/Kimi OAuth", note: "Kimi CLI OAuth reuse plus API-key mode for Moonshot/Kimi sessions" }, ], }, { @@ -43,8 +43,8 @@ const tracksEn = [ { title: "Memory typed store", note: "SQLite + FTS5 backend with graph-structured agent memory and multi-signal recall (#534–#536)" }, { title: "Feishu / Lark bot", note: "Chat-platform frontend over the existing runtime API (#757)" }, { title: "Chinese-market & i18n", note: "Locale-aware UI, platform refinements, region-specific search backends (#755)" }, - { title: "Hugging Face model discovery + Model Lab", note: "Browse, download, and manage models from Hugging Face Hub directly in the TUI" }, - { title: "ZenMux / OpenAI-compatible providers", note: "Bring any OpenAI-compatible endpoint (vLLM, LiteLLM, Ollama, local) as a first-class provider" }, + { title: "Model Lab", note: "Curated model discovery and benchmarking for open-weight and self-hosted workflows" }, + { title: "Provider billing and catalogs", note: "/balance capability layer plus richer live model catalogs for providers that expose listing endpoints" }, ], }, { @@ -92,12 +92,12 @@ const tracksZh = [ { title: "子 Agent 并行执行", note: "agent_open / agent_eval / agent_close;最多 10 个并发会话,通过 var_handle 有界读取结果" }, { title: "RLM 批量处理", note: "持久沙箱 Python REPL,支持 1–16 路廉价并行子调用,处理长文本分析" }, { title: "三种运行模式", note: "Plan(只读)、Agent(默认)、YOLO(自动批准);审批模式正交(建议/自动/拒绝)" }, - { title: "跨平台沙箱", note: "seatbelt(macOS)、landlock(Linux);Windows 通过受限令牌实现基础隔离(功能有限)" }, + { title: "跨平台控制", note: "seatbelt(macOS)、landlock(Linux);Windows 保留审批与终端运行时保护,OS 沙箱仍在跟踪中" }, { title: "持久化会话 + 后台任务", note: "保存、恢复、回滚;后台任务队列,可回放时间线,位于 ~/.deepseek/tasks/" }, - { title: "双向 MCP 协议", note: "消费外部服务器工具;通过 `deepseek mcp` 暴露为服务器;~/.deepseek/mcp.json" }, + { title: "双向 MCP 协议", note: "消费外部服务器工具;通过 `codewhale mcp` 暴露为服务器;~/.deepseek/mcp.json" }, { title: "技能 + 统一命令面板", note: "~/.deepseek/skills/ 自动加载;/help、/mode、/status、/config、/trust、/feedback" }, - { title: "OpenRouter 提供商", note: "原生集成 OpenRouter,支持 300+ 模型,覆盖数十个提供商" }, - { title: "多提供商支持", note: "按会话动态切换提供商(DeepSeek、OpenAI、Anthropic、OpenRouter)" }, + { title: "v0.8.45 提供商表面", note: "DeepSeek、NVIDIA NIM、OpenAI 兼容、AtlasCloud、Wanjie Ark、OpenRouter、Novita、Fireworks、Moonshot/Kimi、SGLang、vLLM、Ollama" }, + { title: "Moonshot/Kimi OAuth", note: "复用 Kimi CLI OAuth,也支持 Moonshot/Kimi API key 模式" }, ], }, { @@ -109,8 +109,8 @@ const tracksZh = [ { title: "记忆类型化存储", note: "SQLite + FTS5 后端,图结构 Agent 记忆,多信号召回(#534–#536)" }, { title: "飞书 / Lark 机器人", note: "基于现有 runtime API 的聊天平台前端(#757)" }, { title: "中国市场与国际化改进", note: "本地化 UI、平台优化、区域搜索引擎(#755)" }, - { title: "Hugging Face 模型发现 + 模型实验室", note: "在 TUI 中直接浏览、下载和管理 Hugging Face Hub 上的模型" }, - { title: "ZenMux / OpenAI 兼容提供商", note: "将任意 OpenAI 兼容端点(vLLM、LiteLLM、Ollama、本地模型)作为一级提供商接入" }, + { title: "模型实验室", note: "面向开放权重和自托管工作流的模型发现与基准测试" }, + { title: "提供商账单与目录", note: "/balance 能力层,以及对支持列表接口的提供商提供更完整的实时模型目录" }, ], }, { @@ -339,4 +339,4 @@ export default async function RoadmapPage({ params }: { params: Promise<{ locale )} ); -} \ No newline at end of file +} diff --git a/web/lib/facts-drift.ts b/web/lib/facts-drift.ts index 5e99f35c..531ebcff 100644 --- a/web/lib/facts-drift.ts +++ b/web/lib/facts-drift.ts @@ -77,12 +77,15 @@ function deriveProvidersFromConfig(cfg: string): ProviderFact[] { // so the binary rejects it — keep it out of the docs. Issue #1104. const labelMap: Record = { Deepseek: { id: "deepseek", label: "DeepSeek", env: "DEEPSEEK_API_KEY" }, - NvidiaNim: { id: "nvidia-nim", label: "NVIDIA NIM", env: "NVIDIA_API_KEY" }, - Openai: { id: "openai", label: "OpenAI", env: "OPENAI_API_KEY" }, + NvidiaNim: { id: "nvidia-nim", label: "NVIDIA NIM", env: "NVIDIA_API_KEY / NVIDIA_NIM_API_KEY" }, + Openai: { id: "openai", label: "OpenAI-compatible", env: "OPENAI_API_KEY" }, + Atlascloud: { id: "atlascloud", label: "AtlasCloud", env: "ATLASCLOUD_API_KEY" }, + WanjieArk: { id: "wanjie-ark", label: "Wanjie Ark", env: "WANJIE_ARK_API_KEY / WANJIE_API_KEY / WANJIE_MAAS_API_KEY" }, Openrouter: { id: "openrouter", label: "OpenRouter", env: "OPENROUTER_API_KEY" }, - Novita: { id: "novita", label: "Novita", env: "NOVITA_API_KEY" }, - Fireworks: { id: "fireworks", label: "Fireworks", env: "FIREWORKS_API_KEY" }, - Sglang: { id: "sglang", label: "sglang", env: "SGLANG_API_KEY" }, + Novita: { id: "novita", label: "Novita AI", env: "NOVITA_API_KEY" }, + Fireworks: { id: "fireworks", label: "Fireworks AI", env: "FIREWORKS_API_KEY" }, + Moonshot: { id: "moonshot", label: "Moonshot/Kimi", env: "MOONSHOT_API_KEY / KIMI_API_KEY" }, + Sglang: { id: "sglang", label: "SGLang", env: "SGLANG_API_KEY" }, Vllm: { id: "vllm", label: "vLLM", env: "VLLM_API_KEY" }, Ollama: { id: "ollama", label: "Ollama", env: "OLLAMA_API_KEY" }, }; @@ -98,7 +101,6 @@ function deriveSandboxBackends(files: string[]): string[] { const map: Record = { seatbelt: "seatbelt (macOS)", landlock: "landlock (Linux)", - windows: "AppContainer / restricted tokens (Windows)", }; return files .map((f) => f.replace(/\.rs$/, "")) diff --git a/web/lib/facts.generated.ts b/web/lib/facts.generated.ts index b4468cf9..0db1a07a 100644 --- a/web/lib/facts.generated.ts +++ b/web/lib/facts.generated.ts @@ -18,8 +18,8 @@ export interface RepoFacts { } export const FACTS: RepoFacts = { - "generatedAt": "2026-05-24T16:01:45.189Z", - "version": "0.8.43", + "generatedAt": "2026-05-26T03:03:01.383Z", + "version": "0.8.45", "crates": [ "agent", "app-server", @@ -38,8 +38,7 @@ export const FACTS: RepoFacts = { ], "sandboxBackends": [ "landlock (Linux)", - "seatbelt (macOS)", - "AppContainer / restricted tokens (Windows)" + "seatbelt (macOS)" ], "providers": [ { @@ -50,13 +49,23 @@ export const FACTS: RepoFacts = { { "id": "nvidia-nim", "label": "NVIDIA NIM", - "env": "NVIDIA_API_KEY" + "env": "NVIDIA_API_KEY / NVIDIA_NIM_API_KEY" }, { "id": "openai", - "label": "OpenAI", + "label": "OpenAI-compatible", "env": "OPENAI_API_KEY" }, + { + "id": "atlascloud", + "label": "AtlasCloud", + "env": "ATLASCLOUD_API_KEY" + }, + { + "id": "wanjie-ark", + "label": "Wanjie Ark", + "env": "WANJIE_ARK_API_KEY / WANJIE_API_KEY / WANJIE_MAAS_API_KEY" + }, { "id": "openrouter", "label": "OpenRouter", @@ -64,17 +73,22 @@ export const FACTS: RepoFacts = { }, { "id": "novita", - "label": "Novita", + "label": "Novita AI", "env": "NOVITA_API_KEY" }, { "id": "fireworks", - "label": "Fireworks", + "label": "Fireworks AI", "env": "FIREWORKS_API_KEY" }, + { + "id": "moonshot", + "label": "Moonshot/Kimi", + "env": "MOONSHOT_API_KEY / KIMI_API_KEY" + }, { "id": "sglang", - "label": "sglang", + "label": "SGLang", "env": "SGLANG_API_KEY" }, { @@ -90,7 +104,7 @@ export const FACTS: RepoFacts = { ], "defaultModel": "deepseek-v4-pro", "nodeEngines": ">=18", - "toolCount": 69, + "toolCount": 70, "license": "MIT", "latestRelease": null }; diff --git a/web/lib/github.ts b/web/lib/github.ts index aeafe692..380c0004 100644 --- a/web/lib/github.ts +++ b/web/lib/github.ts @@ -2,7 +2,7 @@ import type { FeedItem, RepoStats } from "./types"; const REPO = process.env.GITHUB_REPO ?? "Hmbown/CodeWhale"; const GH = "https://api.github.com"; -const MIN_KNOWN_CONTRIBUTORS = 98; +const MIN_KNOWN_CONTRIBUTORS = 99; function headers(token?: string): HeadersInit { const h: Record = { diff --git a/web/lib/roadmap-feed.ts b/web/lib/roadmap-feed.ts index 102f1037..48edda44 100644 --- a/web/lib/roadmap-feed.ts +++ b/web/lib/roadmap-feed.ts @@ -55,6 +55,20 @@ async function gh(url: string, ghToken?: string): Promise { interface GhRelease { tag_name: string; name: string | null; body: string | null; html_url: string; prerelease: boolean; draft: boolean } interface GhIssue { number: number; title: string; html_url: string; body: string | null; state: string; pull_request?: unknown } +const FALLBACK_SHIPPED: RoadmapItem[] = [ + { + title: "v0.8.45", + note: "Moonshot/Kimi OAuth, provider-surface sync, and current Windows install/runtime guidance", + href: "https://github.com/Hmbown/CodeWhale/releases/tag/v0.8.45", + }, +]; + +function withPinnedShipped(items: RoadmapItem[]): RoadmapItem[] { + const seen = new Set(items.map((item) => item.title)); + const pinned = FALLBACK_SHIPPED.filter((item) => !seen.has(item.title)); + return [...pinned, ...items]; +} + function summarizeReleaseBody(body: string | null): string { if (!body) return ""; // First non-empty line, stripped of markdown headers / bullets / links @@ -100,17 +114,19 @@ export async function fetchRoadmap(ghToken?: string): Promise { fetchByLabel("roadmap:ruled-out", ghToken, "all"), ]); - const shipped: RoadmapItem[] = (releases ?? []) + const shipped: RoadmapItem[] = releases + ? releases .filter((r) => !r.draft) .map((r) => ({ title: r.name?.trim() || r.tag_name, note: summarizeReleaseBody(r.body) || r.tag_name, href: r.html_url, - })); + })) + : FALLBACK_SHIPPED; return { generatedAt: new Date().toISOString(), - shipped, + shipped: withPinnedShipped(shipped), underway, considered, ruledOut, @@ -121,7 +137,10 @@ export async function getCachedRoadmap(kv: KVNamespace | undefined, ghToken: str try { if (kv) { const cached = await kv.get(KV_KEY); - if (cached) return JSON.parse(cached) as RoadmapFeed; + if (cached) { + const parsed = JSON.parse(cached) as RoadmapFeed; + return { ...parsed, shipped: withPinnedShipped(parsed.shipped ?? []) }; + } } const fresh = await fetchRoadmap(ghToken); if (kv) { diff --git a/web/scripts/derive-facts.mjs b/web/scripts/derive-facts.mjs index 76b31f2d..b1e7830d 100644 --- a/web/scripts/derive-facts.mjs +++ b/web/scripts/derive-facts.mjs @@ -46,10 +46,10 @@ function deriveSandboxBackends() { const files = readdirSync(dir) .filter((f) => f.endsWith(".rs")) .map((f) => f.replace(/\.rs$/, "")) - .filter((f) => !["mod", "policy", "backend", "opensandbox"].includes(f)) + .filter((f) => !["mod", "policy", "backend", "opensandbox", "windows"].includes(f)) .sort(); // canonicalize platform names - const map = { seatbelt: "seatbelt (macOS)", landlock: "landlock (Linux)", windows: "AppContainer / restricted tokens (Windows)" }; + const map = { seatbelt: "seatbelt (macOS)", landlock: "landlock (Linux)" }; return files.map((f) => map[f] ?? f); } @@ -66,12 +66,15 @@ function deriveProviders() { // shared ProviderKind, so we exclude it until that lands. Issue #1104. const labelMap = { Deepseek: { id: "deepseek", label: "DeepSeek", env: "DEEPSEEK_API_KEY" }, - NvidiaNim: { id: "nvidia-nim", label: "NVIDIA NIM", env: "NVIDIA_API_KEY" }, - Openai: { id: "openai", label: "OpenAI", env: "OPENAI_API_KEY" }, + NvidiaNim: { id: "nvidia-nim", label: "NVIDIA NIM", env: "NVIDIA_API_KEY / NVIDIA_NIM_API_KEY" }, + Openai: { id: "openai", label: "OpenAI-compatible", env: "OPENAI_API_KEY" }, + Atlascloud: { id: "atlascloud", label: "AtlasCloud", env: "ATLASCLOUD_API_KEY" }, + WanjieArk: { id: "wanjie-ark", label: "Wanjie Ark", env: "WANJIE_ARK_API_KEY / WANJIE_API_KEY / WANJIE_MAAS_API_KEY" }, Openrouter: { id: "openrouter", label: "OpenRouter", env: "OPENROUTER_API_KEY" }, - Novita: { id: "novita", label: "Novita", env: "NOVITA_API_KEY" }, - Fireworks: { id: "fireworks", label: "Fireworks", env: "FIREWORKS_API_KEY" }, - Sglang: { id: "sglang", label: "sglang", env: "SGLANG_API_KEY" }, + Novita: { id: "novita", label: "Novita AI", env: "NOVITA_API_KEY" }, + Fireworks: { id: "fireworks", label: "Fireworks AI", env: "FIREWORKS_API_KEY" }, + Moonshot: { id: "moonshot", label: "Moonshot/Kimi", env: "MOONSHOT_API_KEY / KIMI_API_KEY" }, + Sglang: { id: "sglang", label: "SGLang", env: "SGLANG_API_KEY" }, Vllm: { id: "vllm", label: "vLLM", env: "VLLM_API_KEY" }, Ollama: { id: "ollama", label: "Ollama", env: "OLLAMA_API_KEY" }, }; From 0628adab38f1a0515ebb6306ab9b5a1c9171136d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 03:47:42 +0000 Subject: [PATCH 019/283] test: add table-driven env-forwarding regression test for all providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers Openrouter, Novita, NvidiaNim, Fireworks, Sglang, Vllm, Ollama, Atlascloud, and WanjieArk — the providers that were silently expanded by the generic provider_env_vars loop but had no test coverage beyond the existing Moonshot and OpenAI cases. Co-Authored-By: bot_apk --- crates/cli/src/lib.rs | 78 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 18cc93d8..18f2c076 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -2732,6 +2732,84 @@ mod tests { ); } + #[test] + fn build_tui_command_forwards_provider_env_vars_for_all_providers() { + let _lock = env_lock(); + let dir = tempfile::TempDir::new().expect("tempdir"); + let custom = dir + .path() + .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX)); + std::fs::write(&custom, b"").unwrap(); + let custom_str = custom.to_string_lossy().into_owned(); + let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str); + + // (provider, cli flag, extra env vars that must be forwarded besides DEEPSEEK_API_KEY) + let cases: &[(ProviderKind, &str, &[&str])] = &[ + (ProviderKind::Openrouter, "openrouter", &["OPENROUTER_API_KEY"]), + (ProviderKind::Novita, "novita", &["NOVITA_API_KEY"]), + ( + ProviderKind::NvidiaNim, + "nvidia-nim", + &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY"], + ), + (ProviderKind::Fireworks, "fireworks", &["FIREWORKS_API_KEY"]), + (ProviderKind::Sglang, "sglang", &["SGLANG_API_KEY"]), + (ProviderKind::Vllm, "vllm", &["VLLM_API_KEY"]), + (ProviderKind::Ollama, "ollama", &["OLLAMA_API_KEY"]), + ( + ProviderKind::Atlascloud, + "atlascloud", + &["ATLASCLOUD_API_KEY"], + ), + ( + ProviderKind::WanjieArk, + "wanjie-ark", + &["WANJIE_ARK_API_KEY", "WANJIE_API_KEY", "WANJIE_MAAS_API_KEY"], + ), + ]; + + for &(provider, flag, expected_vars) in cases { + let cli = parse_ok(&[ + "codewhale", + "--provider", + flag, + "--workspace", + "/tmp/codewhale-workspace", + ]); + let resolved = ResolvedRuntimeOptions { + provider, + model: "test-model".to_string(), + api_key: Some("test-key".to_string()), + api_key_source: Some(RuntimeApiKeySource::Env), + base_url: "http://localhost:8000/v1".to_string(), + auth_mode: Some("api_key".to_string()), + output_mode: None, + log_level: None, + telemetry: false, + approval_policy: None, + sandbox_mode: None, + yolo: None, + http_headers: std::collections::BTreeMap::new(), + }; + + let cmd = build_tui_command(&cli, &resolved, Vec::new()) + .unwrap_or_else(|e| panic!("{flag}: {e}")); + + assert_eq!( + command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(), + Some("test-key"), + "{flag}: DEEPSEEK_API_KEY not forwarded" + ); + for var in expected_vars { + assert_eq!( + command_env(&cmd, var).as_deref(), + Some("test-key"), + "{flag}: {var} not forwarded" + ); + } + } + } + #[test] fn parses_top_level_prompt_flag_for_canonical_one_shot() { let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]); From c4e91446affcfe6f3df47132bb5fa57de1923aa8 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 25 May 2026 23:07:12 -0500 Subject: [PATCH 020/283] test(cli): format provider env regression cases --- crates/cli/src/lib.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 18f2c076..3eaeb3c0 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -2745,7 +2745,11 @@ mod tests { // (provider, cli flag, extra env vars that must be forwarded besides DEEPSEEK_API_KEY) let cases: &[(ProviderKind, &str, &[&str])] = &[ - (ProviderKind::Openrouter, "openrouter", &["OPENROUTER_API_KEY"]), + ( + ProviderKind::Openrouter, + "openrouter", + &["OPENROUTER_API_KEY"], + ), (ProviderKind::Novita, "novita", &["NOVITA_API_KEY"]), ( ProviderKind::NvidiaNim, @@ -2764,7 +2768,11 @@ mod tests { ( ProviderKind::WanjieArk, "wanjie-ark", - &["WANJIE_ARK_API_KEY", "WANJIE_API_KEY", "WANJIE_MAAS_API_KEY"], + &[ + "WANJIE_ARK_API_KEY", + "WANJIE_API_KEY", + "WANJIE_MAAS_API_KEY", + ], ), ]; From ab38635f782c48104635d018a50a163a38065efb Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 25 May 2026 23:39:34 -0500 Subject: [PATCH 021/283] fix(kimi): support API-key setup for Kimi Code --- CHANGELOG.md | 12 ++- README.md | 26 ++++--- crates/config/src/lib.rs | 122 +++++++++++++++++++++++++++++- crates/tui/CHANGELOG.md | 12 ++- crates/tui/src/config.rs | 64 ++++++++++++++-- web/app/[locale]/docs/page.tsx | 10 +++ web/app/[locale]/faq/page.tsx | 4 +- web/app/[locale]/roadmap/page.tsx | 4 +- web/lib/facts.ts | 4 + web/lib/kv.ts | 24 ++++-- web/lib/roadmap-feed.ts | 2 +- 11 files changed, 248 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f21e957..2d0f05ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Kimi Code API-key setup.** `codewhale config set providers.moonshot.*` + now writes the Moonshot/Kimi provider table, and Kimi Code API-key + endpoints default to `kimi-for-coding` without using the Kimi CLI OAuth path. + ## [0.8.45] - 2026-05-25 ### Added @@ -17,9 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Command palette voice input.** The command palette can launch a configured speech-to-text helper and show footer status while transcription runs (#2047). -- **Moonshot/Kimi OAuth provider.** Moonshot/Kimi is now a first-class - provider, including Kimi CLI OAuth reuse, secure refresh writes, model - completion, CLI auth, and secret-store integration. +- **Moonshot/Kimi provider.** Moonshot/Kimi is now a first-class provider, + including API-key auth, model completion, CLI auth, secret-store + integration, and optional Kimi CLI credential reuse. - **Deterministic whale-species sub-agent names.** Sub-agents now get stable, human-readable whale-species nicknames (e.g. "Beluga", "Orca") while preserving the raw agent ID in the popup (#2035, #2016). diff --git a/README.md b/README.md index d1dd66a1..450ca64d 100644 --- a/README.md +++ b/README.md @@ -314,15 +314,23 @@ codewhale --provider novita --model deepseek/deepseek-v4-pro codewhale auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY" codewhale --provider fireworks --model deepseek-v4-pro -# Moonshot/Kimi -codewhale auth set --provider moonshot --api-key "YOUR_MOONSHOT_OR_KIMI_API_KEY" -codewhale --provider moonshot --model kimi-k2.6 +# Kimi Code plan API key +codewhale auth set --provider moonshot --api-key "YOUR_KIMI_CODE_API_KEY" +codewhale config set providers.moonshot.auth_mode api_key +codewhale config set providers.moonshot.base_url https://api.kimi.com/coding/v1 +codewhale config set providers.moonshot.model kimi-for-coding +codewhale --provider moonshot -# Moonshot/Kimi with Kimi CLI OAuth -kimi login -mkdir -p ~/.deepseek -printf 'provider = "moonshot"\n\n[providers.moonshot]\nauth_mode = "kimi_oauth"\n' >> ~/.deepseek/config.toml -codewhale --provider moonshot --model kimi-for-coding +# Kimi/Moonshot Platform API key +codewhale auth set --provider moonshot --api-key "YOUR_MOONSHOT_OR_KIMI_API_KEY" +codewhale config set providers.moonshot.auth_mode api_key +codewhale config set providers.moonshot.base_url https://api.moonshot.ai/v1 +codewhale config set providers.moonshot.model kimi-k2.6 +codewhale --provider moonshot + +# Kimi through OpenRouter's catalog +codewhale auth set --provider openrouter --api-key "YOUR_OPENROUTER_API_KEY" +codewhale --provider openrouter --model moonshotai/kimi-k2.6 # Self-hosted SGLang SGLANG_BASE_URL="http://localhost:30000/v1" codewhale --provider sglang --model deepseek-v4-flash @@ -512,7 +520,7 @@ Key environment variables: | `OPENAI_BASE_URL` / `OPENAI_MODEL` | Generic OpenAI-compatible endpoint and model ID | | `ATLASCLOUD_BASE_URL` / `ATLASCLOUD_MODEL` | AtlasCloud endpoint and model override | | `WANJIE_ARK_BASE_URL` / `WANJIE_BASE_URL` / `WANJIE_MAAS_BASE_URL` / `WANJIE_ARK_MODEL` / `WANJIE_MODEL` / `WANJIE_MAAS_MODEL` | Wanjie Ark endpoint and model override | -| `MOONSHOT_BASE_URL` / `KIMI_BASE_URL` / `MOONSHOT_MODEL` / `KIMI_MODEL_NAME` / `KIMI_MODEL` | Moonshot/Kimi endpoint and model override | +| `MOONSHOT_BASE_URL` / `KIMI_BASE_URL` / `MOONSHOT_MODEL` / `KIMI_MODEL_NAME` / `KIMI_MODEL` | Moonshot/Kimi endpoint and model override. For a Kimi Code plan API key, use `KIMI_BASE_URL=https://api.kimi.com/coding/v1` and `KIMI_MODEL=kimi-for-coding`. | | `OPENROUTER_BASE_URL` | OpenRouter endpoint override | | `NOVITA_BASE_URL` | Novita endpoint override | | `FIREWORKS_BASE_URL` | Fireworks endpoint override | diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 576de517..d9d72864 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -462,6 +462,13 @@ impl ConfigToml { "providers.fireworks.http_headers" => { serialize_http_headers(&self.providers.fireworks.http_headers) } + "providers.moonshot.api_key" => self.providers.moonshot.api_key.clone(), + "providers.moonshot.base_url" => self.providers.moonshot.base_url.clone(), + "providers.moonshot.model" => self.providers.moonshot.model.clone(), + "providers.moonshot.auth_mode" => self.providers.moonshot.auth_mode.clone(), + "providers.moonshot.http_headers" => { + serialize_http_headers(&self.providers.moonshot.http_headers) + } "providers.sglang.api_key" => self.providers.sglang.api_key.clone(), "providers.sglang.base_url" => self.providers.sglang.base_url.clone(), "providers.sglang.model" => self.providers.sglang.model.clone(), @@ -612,6 +619,21 @@ impl ConfigToml { "providers.fireworks.http_headers" => { self.providers.fireworks.http_headers = parse_http_headers(value)?; } + "providers.moonshot.api_key" => { + self.providers.moonshot.api_key = Some(value.to_string()); + } + "providers.moonshot.base_url" => { + self.providers.moonshot.base_url = Some(value.to_string()); + } + "providers.moonshot.model" => { + self.providers.moonshot.model = Some(value.to_string()); + } + "providers.moonshot.auth_mode" => { + self.providers.moonshot.auth_mode = Some(value.to_string()); + } + "providers.moonshot.http_headers" => { + self.providers.moonshot.http_headers = parse_http_headers(value)?; + } "providers.sglang.api_key" => { self.providers.sglang.api_key = Some(value.to_string()); } @@ -716,6 +738,11 @@ impl ConfigToml { "providers.fireworks.base_url" => self.providers.fireworks.base_url = None, "providers.fireworks.model" => self.providers.fireworks.model = None, "providers.fireworks.http_headers" => self.providers.fireworks.http_headers.clear(), + "providers.moonshot.api_key" => self.providers.moonshot.api_key = None, + "providers.moonshot.base_url" => self.providers.moonshot.base_url = None, + "providers.moonshot.model" => self.providers.moonshot.model = None, + "providers.moonshot.auth_mode" => self.providers.moonshot.auth_mode = None, + "providers.moonshot.http_headers" => self.providers.moonshot.http_headers.clear(), "providers.sglang.api_key" => self.providers.sglang.api_key = None, "providers.sglang.base_url" => self.providers.sglang.base_url = None, "providers.sglang.model" => self.providers.sglang.model = None, @@ -869,6 +896,21 @@ impl ConfigToml { if let Some(v) = serialize_http_headers(&self.providers.fireworks.http_headers) { out.insert("providers.fireworks.http_headers".to_string(), v); } + if let Some(v) = self.providers.moonshot.api_key.as_ref() { + out.insert("providers.moonshot.api_key".to_string(), redact_secret(v)); + } + if let Some(v) = self.providers.moonshot.base_url.as_ref() { + out.insert("providers.moonshot.base_url".to_string(), v.clone()); + } + if let Some(v) = self.providers.moonshot.model.as_ref() { + out.insert("providers.moonshot.model".to_string(), v.clone()); + } + if let Some(v) = self.providers.moonshot.auth_mode.as_ref() { + out.insert("providers.moonshot.auth_mode".to_string(), v.clone()); + } + if let Some(v) = serialize_http_headers(&self.providers.moonshot.http_headers) { + out.insert("providers.moonshot.http_headers".to_string(), v); + } if let Some(v) = self.providers.sglang.api_key.as_ref() { out.insert("providers.sglang.api_key".to_string(), redact_secret(v)); } @@ -1028,7 +1070,8 @@ impl ConfigToml { .or_else(|| self.model.clone()) .unwrap_or_else(|| { if provider == ProviderKind::Moonshot - && auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth) + && (auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth) + || moonshot_base_url_uses_kimi_code(&base_url)) { DEFAULT_KIMI_CODE_MODEL.to_string() } else { @@ -1257,6 +1300,13 @@ fn default_base_url_for_provider(provider: ProviderKind) -> &'static str { } } +fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool { + let normalized = base_url.trim_end_matches('/').to_ascii_lowercase(); + normalized == DEFAULT_KIMI_CODE_BASE_URL + || normalized == "https://api.kimi.com/coding" + || normalized.starts_with("https://api.kimi.com/coding/") +} + fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bool { let actual = base_url.trim_end_matches('/'); let default = default_base_url_for_provider(provider).trim_end_matches('/'); @@ -2358,6 +2408,52 @@ mod tests { ); } + #[test] + fn moonshot_provider_config_values_round_trip() -> Result<()> { + let mut config = ConfigToml::default(); + + config.set_value("providers.moonshot.api_key", "moonshot-secret-value")?; + config.set_value("providers.moonshot.base_url", DEFAULT_KIMI_CODE_BASE_URL)?; + config.set_value("providers.moonshot.model", DEFAULT_KIMI_CODE_MODEL)?; + config.set_value("providers.moonshot.auth_mode", "api_key")?; + config.set_value("providers.moonshot.http_headers", "X-Test=ok")?; + + assert_eq!( + config + .get_display_value("providers.moonshot.api_key") + .as_deref(), + Some("moon***alue") + ); + assert_eq!( + config.get_value("providers.moonshot.base_url").as_deref(), + Some(DEFAULT_KIMI_CODE_BASE_URL) + ); + assert_eq!( + config.get_value("providers.moonshot.model").as_deref(), + Some(DEFAULT_KIMI_CODE_MODEL) + ); + assert_eq!( + config.get_value("providers.moonshot.auth_mode").as_deref(), + Some("api_key") + ); + assert_eq!( + config + .list_values() + .get("providers.moonshot.api_key") + .map(String::as_str), + Some("moon***alue") + ); + + config.unset_value("providers.moonshot.auth_mode")?; + config.unset_value("providers.moonshot.base_url")?; + config.unset_value("providers.moonshot.model")?; + + assert_eq!(config.get_value("providers.moonshot.auth_mode"), None); + assert_eq!(config.get_value("providers.moonshot.base_url"), None); + assert_eq!(config.get_value("providers.moonshot.model"), None); + Ok(()) + } + #[test] fn project_merge_denies_credentials_endpoints_and_provider_selection() { let mut base = ConfigToml { @@ -2637,6 +2733,30 @@ mod tests { assert_eq!(resolved.api_key_source, None); } + #[test] + fn moonshot_kimi_code_api_key_endpoint_defaults_to_kimi_for_coding() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml { + provider: ProviderKind::Moonshot, + ..ConfigToml::default() + }; + config.providers.moonshot.api_key = Some("kimi-code-key".to_string()); + config.providers.moonshot.base_url = Some(DEFAULT_KIMI_CODE_BASE_URL.to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.auth_mode, None); + assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL); + assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL); + assert_eq!(resolved.api_key.as_deref(), Some("kimi-code-key")); + assert_eq!( + resolved.api_key_source, + Some(RuntimeApiKeySource::ConfigFile) + ); + } + #[test] fn wanjie_ark_provider_defaults_to_openai_compatible_endpoint_and_model() { let _lock = env_lock(); diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 7f21e957..2d0f05ae 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Kimi Code API-key setup.** `codewhale config set providers.moonshot.*` + now writes the Moonshot/Kimi provider table, and Kimi Code API-key + endpoints default to `kimi-for-coding` without using the Kimi CLI OAuth path. + ## [0.8.45] - 2026-05-25 ### Added @@ -17,9 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Command palette voice input.** The command palette can launch a configured speech-to-text helper and show footer status while transcription runs (#2047). -- **Moonshot/Kimi OAuth provider.** Moonshot/Kimi is now a first-class - provider, including Kimi CLI OAuth reuse, secure refresh writes, model - completion, CLI auth, and secret-store integration. +- **Moonshot/Kimi provider.** Moonshot/Kimi is now a first-class provider, + including API-key auth, model completion, CLI auth, secret-store + integration, and optional Kimi CLI credential reuse. - **Deterministic whale-species sub-agent names.** Sub-agents now get stable, human-readable whale-species nicknames (e.g. "Beluga", "Orca") while preserving the raw agent ID in the popup (#2035, #2016). diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index ce15b29e..78975ee3 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1570,11 +1570,17 @@ impl Config { { return model_for_provider(provider, normalized); } - if provider == ApiProvider::Moonshot - && self - .provider_config() - .is_some_and(provider_config_uses_kimi_oauth) - { + let moonshot_config = (provider == ApiProvider::Moonshot) + .then(|| self.provider_config()) + .flatten(); + let moonshot_uses_kimi_code = moonshot_config.is_some_and(|config| { + provider_config_uses_kimi_oauth(config) + || config + .base_url + .as_deref() + .is_some_and(moonshot_base_url_uses_kimi_code) + }); + if moonshot_uses_kimi_code { return DEFAULT_KIMI_CODE_MODEL.to_string(); } @@ -1771,8 +1777,9 @@ impl Config { ), ApiProvider::Moonshot => anyhow::bail!( "Moonshot/Kimi API key not found. Run 'codewhale auth set --provider moonshot', \ - set MOONSHOT_API_KEY/KIMI_API_KEY, add [providers.moonshot] api_key, \ - or run `kimi login` and set [providers.moonshot] auth_mode = \"kimi_oauth\"." + set MOONSHOT_API_KEY/KIMI_API_KEY, or add [providers.moonshot] api_key. \ + For a Kimi Code plan key, set [providers.moonshot] base_url = \ + \"https://api.kimi.com/coding/v1\" and model = \"kimi-for-coding\"." ), // Self-hosted deployments commonly run without auth on localhost. // Return an empty key and let the client omit the Authorization header. @@ -2880,6 +2887,13 @@ fn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &st base_url_is_custom_for_provider(provider, base_url) } +fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool { + let normalized = normalize_base_url(base_url).to_ascii_lowercase(); + normalized == DEFAULT_KIMI_CODE_BASE_URL + || normalized == "https://api.kimi.com/coding" + || normalized.starts_with("https://api.kimi.com/coding/") +} + fn provider_config_uses_kimi_oauth(config: &ProviderConfig) -> bool { config .auth_mode @@ -6434,6 +6448,42 @@ api_key = "stale-api-key" Ok(()) } + #[test] + fn moonshot_kimi_code_api_key_uses_coding_model() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-kimi-code-key-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + let config_path = temp_root.join(".deepseek").join("config.toml"); + ensure_parent_dir(&config_path)?; + fs::write( + &config_path, + r#"provider = "moonshot" + +[providers.moonshot] +api_key = "kimi-code-key" +base_url = "https://api.kimi.com/coding/v1" +"#, + )?; + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Moonshot); + assert_eq!(config.deepseek_base_url(), DEFAULT_KIMI_CODE_BASE_URL); + assert_eq!(config.default_model(), DEFAULT_KIMI_CODE_MODEL); + assert_eq!(config.deepseek_api_key()?, "kimi-code-key"); + assert!(has_api_key_for(&config, ApiProvider::Moonshot)); + Ok(()) + } + #[test] fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> { let _lock = lock_test_env(); diff --git a/web/app/[locale]/docs/page.tsx b/web/app/[locale]/docs/page.tsx index 78be0f71..5a639add 100644 --- a/web/app/[locale]/docs/page.tsx +++ b/web/app/[locale]/docs/page.tsx @@ -267,6 +267,11 @@ command = "~/.deepseek/hooks/pre.sh" # / message_submit / mode_change /

开放模型平台方向:CodeWhale 保持 DeepSeek 优先,同时内置 Moonshot/Kimi、OpenRouter、NVIDIA NIM、 AtlasCloud、Wanjie Ark、Novita、Fireworks 和自托管 SGLang/vLLM/Ollama 路径。 + Kimi Code 会员 API Key 使用 providers.moonshot.base_url + 指向 https://api.kimi.com/coding/v1,模型为 + kimi-for-coding;Kimi/Moonshot 平台 API Key 继续使用 + https://api.moonshot.ai/v1 和 + kimi-k2.6

@@ -515,6 +520,11 @@ command = "~/.deepseek/hooks/pre.sh" # / message_submit / mode_change / Open-model platform direction: CodeWhale stays DeepSeek-first while shipping Moonshot/Kimi, OpenRouter, NVIDIA NIM, AtlasCloud, Wanjie Ark, Novita, Fireworks, and self-hosted SGLang/vLLM/Ollama paths. + Kimi Code membership API keys use providers.moonshot.base_url + set to https://api.kimi.com/coding/v1 with + kimi-for-coding; Kimi/Moonshot Platform API keys use + https://api.moonshot.ai/v1 with + kimi-k2.6.

diff --git a/web/app/[locale]/faq/page.tsx b/web/app/[locale]/faq/page.tsx index ec5f7dcd..2d9db748 100644 --- a/web/app/[locale]/faq/page.tsx +++ b/web/app/[locale]/faq/page.tsx @@ -112,7 +112,7 @@ codewhale doctor # full connectivity check`}

CodeWhale ships with these built-in providers:

  • DeepSeek — first-class, native API. Reasoning streaming, cache metrics, thinking effort control.
  • -
  • Moonshot/Kimi — Kimi API key mode or local Kimi CLI OAuth reuse.
  • +
  • Moonshot/Kimi — Kimi Code and Kimi/Moonshot Platform API-key modes.
  • OpenRouter — unified API for DeepSeek models and more.
  • OpenAI-compatible, NVIDIA NIM, AtlasCloud, Wanjie Ark, Novita, Fireworks, SGLang, vLLM, Ollama
@@ -425,7 +425,7 @@ codewhale doctor # 完整连接检查`}

CodeWhale 内建以下提供商:

  • DeepSeek — 一级支持,原生 API。推理流、缓存指标、思考力度控制。
  • -
  • Moonshot/Kimi — Kimi API key 模式或复用本地 Kimi CLI OAuth。
  • +
  • Moonshot/Kimi — Kimi Code 与 Kimi/Moonshot 平台 API key 模式。
  • OpenRouter — 统一 API,可访问 DeepSeek 等模型。
  • OpenAI 兼容NVIDIA NIMAtlasCloudWanjie ArkNovitaFireworksSGLangvLLMOllama
diff --git a/web/app/[locale]/roadmap/page.tsx b/web/app/[locale]/roadmap/page.tsx index 787ff0c6..3bcc5a46 100644 --- a/web/app/[locale]/roadmap/page.tsx +++ b/web/app/[locale]/roadmap/page.tsx @@ -31,7 +31,7 @@ const tracksEn = [ { title: "Bidirectional MCP", note: "Consume tools from external servers; expose as server via `codewhale mcp`; ~/.deepseek/mcp.json" }, { title: "Skills + unified slash palette", note: "~/.deepseek/skills/ auto-loading; /help, /mode, /status, /config, /trust, /feedback" }, { title: "v0.8.45 provider surface", note: "DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, OpenRouter, Novita, Fireworks, Moonshot/Kimi, SGLang, vLLM, and Ollama" }, - { title: "Moonshot/Kimi OAuth", note: "Kimi CLI OAuth reuse plus API-key mode for Moonshot/Kimi sessions" }, + { title: "Moonshot/Kimi API-key setup", note: "Kimi Code plan and Kimi/Moonshot Platform API-key paths for Moonshot/Kimi sessions" }, ], }, { @@ -97,7 +97,7 @@ const tracksZh = [ { title: "双向 MCP 协议", note: "消费外部服务器工具;通过 `codewhale mcp` 暴露为服务器;~/.deepseek/mcp.json" }, { title: "技能 + 统一命令面板", note: "~/.deepseek/skills/ 自动加载;/help、/mode、/status、/config、/trust、/feedback" }, { title: "v0.8.45 提供商表面", note: "DeepSeek、NVIDIA NIM、OpenAI 兼容、AtlasCloud、Wanjie Ark、OpenRouter、Novita、Fireworks、Moonshot/Kimi、SGLang、vLLM、Ollama" }, - { title: "Moonshot/Kimi OAuth", note: "复用 Kimi CLI OAuth,也支持 Moonshot/Kimi API key 模式" }, + { title: "Moonshot/Kimi API-key 设置", note: "Kimi Code 会员与 Kimi/Moonshot 平台 API key 路径" }, ], }, { diff --git a/web/lib/facts.ts b/web/lib/facts.ts index 25ec174b..6e7514ce 100644 --- a/web/lib/facts.ts +++ b/web/lib/facts.ts @@ -11,6 +11,10 @@ interface KVNamespace { } async function getKv(): Promise { + if (process.env.NEXT_PHASE === "phase-production-build") { + return undefined; + } + try { const mod = await import("@opennextjs/cloudflare"); const ctx = await mod.getCloudflareContext({ async: true }); diff --git a/web/lib/kv.ts b/web/lib/kv.ts index c6048b3a..f3a4bab6 100644 --- a/web/lib/kv.ts +++ b/web/lib/kv.ts @@ -23,20 +23,28 @@ interface CloudflareEnv { GITHUB_REPO?: string; } +function envFromProcess(): CloudflareEnv { + return { + DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY, + DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL, + DEEPSEEK_MODEL: process.env.DEEPSEEK_MODEL, + GITHUB_TOKEN: process.env.GITHUB_TOKEN, + CRON_SECRET: process.env.CRON_SECRET, + GITHUB_REPO: process.env.GITHUB_REPO, + }; +} + export async function getEnv(): Promise { + if (process.env.NEXT_PHASE === "phase-production-build") { + return envFromProcess(); + } + try { const mod = await import("@opennextjs/cloudflare"); const ctx = await mod.getCloudflareContext({ async: true }); return ctx.env as CloudflareEnv; } catch { - return { - DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY, - DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL, - DEEPSEEK_MODEL: process.env.DEEPSEEK_MODEL, - GITHUB_TOKEN: process.env.GITHUB_TOKEN, - CRON_SECRET: process.env.CRON_SECRET, - GITHUB_REPO: process.env.GITHUB_REPO, - }; + return envFromProcess(); } } diff --git a/web/lib/roadmap-feed.ts b/web/lib/roadmap-feed.ts index 48edda44..4c1e4606 100644 --- a/web/lib/roadmap-feed.ts +++ b/web/lib/roadmap-feed.ts @@ -58,7 +58,7 @@ interface GhIssue { number: number; title: string; html_url: string; body: strin const FALLBACK_SHIPPED: RoadmapItem[] = [ { title: "v0.8.45", - note: "Moonshot/Kimi OAuth, provider-surface sync, and current Windows install/runtime guidance", + note: "Moonshot/Kimi provider support, API-key setup guidance, provider-surface sync, and current Windows install/runtime guidance", href: "https://github.com/Hmbown/CodeWhale/releases/tag/v0.8.45", }, ]; From 6bc8363265a0041a72b79df00e521c25dd722986 Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Tue, 26 May 2026 12:40:10 +0800 Subject: [PATCH 022/283] feat: add SlopLedger for tracking unresolved architectural residue (#2127) --- crates/tui/src/commands/config.rs | 41 + crates/tui/src/commands/mod.rs | 10 + crates/tui/src/core/engine/tool_setup.rs | 3 +- crates/tui/src/main.rs | 1 + crates/tui/src/slop_ledger.rs | 1086 ++++++++++++++++++++++ crates/tui/src/tools/registry.rs | 16 + 6 files changed, 1156 insertions(+), 1 deletion(-) create mode 100644 crates/tui/src/slop_ledger.rs diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 40ffe1dc..30c08c61 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -699,6 +699,47 @@ pub fn theme(app: &mut App, arg: Option<&str>) -> CommandResult { } } +/// `/slop [query|export]` — inspect or export the slop ledger (#2127). +/// With no arguments, prints a summary. `query` shows filtered results; +/// `export` outputs the full ledger as Markdown. +pub fn slop(_app: &mut App, arg: Option<&str>) -> CommandResult { + let arg = arg.map(str::trim).unwrap_or(""); + let ledger = match crate::slop_ledger::SlopLedger::load() { + Ok(l) => l, + Err(e) => return CommandResult::error(format!("Failed to load slop ledger: {e}")), + }; + + match arg { + "" => CommandResult::message(ledger.summary()), + "query" | "q" => { + if ledger.is_empty() { + return CommandResult::message("Slop ledger is empty."); + } + let mut out = String::new(); + for entry in &ledger.query(&Default::default()) { + use std::fmt::Write; + let _ = writeln!( + out, + "[{}] {} ({:?} | {:?}) — {}", + &entry.id[..8], + entry.bucket.as_str(), + entry.severity, + entry.status, + entry.title + ); + } + CommandResult::message(out) + } + "export" | "e" => { + let md = ledger.export_markdown(None, None); + CommandResult::message(md) + } + _ => CommandResult::error(format!( + "Unknown /slop action '{arg}'. Use /slop, /slop query, or /slop export." + )), + } +} + /// Manage workspace-level trust and the per-path allowlist. /// /// Subcommands: diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index b1e9f3dd..e6afed10 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -540,6 +540,13 @@ pub const COMMANDS: &[CommandInfo] = &[ usage: "/cache [count|inspect|warmup]", description_id: MessageId::CmdCacheDescription, }, + // Slop Ledger (#2127) + CommandInfo { + name: "slop", + aliases: &["canzha"], + usage: "/slop [query|export]", + description_id: MessageId::CmdHelpDescription, + }, ]; /// Execute a slash command @@ -614,6 +621,9 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "balance" => balance::balance(app), "cache" => debug::cache(app, arg), + // Slop ledger (#2127) + "slop" | "canzha" => config::slop(app, arg), + // ChangeLog command "change" => change::change(app, arg), "system" | "xitong" => debug::system_prompt(app), diff --git a/crates/tui/src/core/engine/tool_setup.rs b/crates/tui/src/core/engine/tool_setup.rs index 2354d6a8..2f2845b0 100644 --- a/crates/tui/src/core/engine/tool_setup.rs +++ b/crates/tui/src/core/engine/tool_setup.rs @@ -63,7 +63,8 @@ impl Engine { .with_review_tool(self.deepseek_client.clone(), self.session.model.clone()) .with_user_input_tool() .with_parallel_tool() - .with_recall_archive_tool(); + .with_recall_archive_tool() + .with_slop_ledger_tools(); if mode != AppMode::Plan { builder = builder diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index be286978..ba082137 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -69,6 +69,7 @@ mod snapshot; mod task_manager; #[cfg(test)] mod test_support; +mod slop_ledger; mod theme_qa_audit; mod tools; mod tui; diff --git a/crates/tui/src/slop_ledger.rs b/crates/tui/src/slop_ledger.rs new file mode 100644 index 00000000..4aca4509 --- /dev/null +++ b/crates/tui/src/slop_ledger.rs @@ -0,0 +1,1086 @@ +//! Slop Ledger — durable tracking of unresolved architectural residue. +//! +//! AI agents often leave behind invisible "slop" after a task: +//! compatibility shims, unmigrated callers, duplicated concepts, +//! naming drift, stale docs/tests, suspected dead code, and tool gaps. +//! +//! The Slop Ledger makes this residue **visible and queryable** so the +//! next agent (or human) doesn't rediscover it, amplify it, or mistake +//! it for intended architecture. +//! +//! ## Design +//! +//! - **Storage**: `~/.codewhale/slop_ledger.json` (a JSON array of entries). +//! - **Schema**: each entry has a bucket, severity, confidence, owner, +//! source links, status, cleanup recommendation, and timestamps. +//! - **Tools**: `slop_ledger_append`, `slop_ledger_query`, +//! `slop_ledger_update`, `slop_ledger_export`. +//! - **Integration**: entries can link to durable tasks and threads; +//! the export path produces a redacted Markdown handoff suitable for +//! GitHub issues or compaction relays. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::fs; +use std::io; +use std::path::PathBuf; +use uuid::Uuid; + +use crate::tools::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, required_str, +}; + +// ── Enums ────────────────────────────────────────────────────────────────── + +/// Classification bucket for a slop entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SlopBucket { + RetainedCompatibility, + UnmigratedCallers, + DuplicateConcepts, + NamingDrift, + StaleDocs, + StaleTests, + SuspectedDeadCode, + UnverifiedPublicBehavior, + ToolGaps, + AcceptedDebt, +} + +impl SlopBucket { + pub fn as_str(self) -> &'static str { + match self { + Self::RetainedCompatibility => "retained_compatibility", + Self::UnmigratedCallers => "unmigrated_callers", + Self::DuplicateConcepts => "duplicate_concepts", + Self::NamingDrift => "naming_drift", + Self::StaleDocs => "stale_docs", + Self::StaleTests => "stale_tests", + Self::SuspectedDeadCode => "suspected_dead_code", + Self::UnverifiedPublicBehavior => "unverified_public_behavior", + Self::ToolGaps => "tool_gaps", + Self::AcceptedDebt => "accepted_debt", + } + } + + pub fn from_str(s: &str) -> Option { + match s.trim().to_lowercase().as_str() { + "retained_compatibility" => Some(Self::RetainedCompatibility), + "unmigrated_callers" => Some(Self::UnmigratedCallers), + "duplicate_concepts" => Some(Self::DuplicateConcepts), + "naming_drift" => Some(Self::NamingDrift), + "stale_docs" => Some(Self::StaleDocs), + "stale_tests" => Some(Self::StaleTests), + "suspected_dead_code" => Some(Self::SuspectedDeadCode), + "unverified_public_behavior" => Some(Self::UnverifiedPublicBehavior), + "tool_gaps" => Some(Self::ToolGaps), + "accepted_debt" => Some(Self::AcceptedDebt), + _ => None, + } + } + + pub fn all_buckets() -> &'static [SlopBucket] { + &[ + Self::RetainedCompatibility, + Self::UnmigratedCallers, + Self::DuplicateConcepts, + Self::NamingDrift, + Self::StaleDocs, + Self::StaleTests, + Self::SuspectedDeadCode, + Self::UnverifiedPublicBehavior, + Self::ToolGaps, + Self::AcceptedDebt, + ] + } +} + +/// Severity of the residue. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SlopSeverity { + Critical, + High, + Medium, + Low, + Info, +} + +impl SlopSeverity { + pub fn from_str(s: &str) -> Option { + match s.trim().to_lowercase().as_str() { + "critical" => Some(Self::Critical), + "high" => Some(Self::High), + "medium" => Some(Self::Medium), + "low" => Some(Self::Low), + "info" => Some(Self::Info), + _ => None, + } + } +} + +/// Confidence in the assessment. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SlopConfidence { + Certain, + High, + Medium, + Low, +} + +impl SlopConfidence { + pub fn from_str(s: &str) -> Option { + match s.trim().to_lowercase().as_str() { + "certain" => Some(Self::Certain), + "high" => Some(Self::High), + "medium" => Some(Self::Medium), + "low" => Some(Self::Low), + _ => None, + } + } +} + +/// Lifecycle status of a slop entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SlopEntryStatus { + Open, + InProgress, + Resolved, + Accepted, + WontFix, +} + +impl SlopEntryStatus { + pub fn from_str(s: &str) -> Option { + match s.trim().to_lowercase().as_str() { + "open" => Some(Self::Open), + "in_progress" | "inprogress" => Some(Self::InProgress), + "resolved" | "done" => Some(Self::Resolved), + "accepted" => Some(Self::Accepted), + "wontfix" | "wont_fix" => Some(Self::WontFix), + _ => None, + } + } +} + +// ── Core data structures ─────────────────────────────────────────────────── + +/// A single slop ledger entry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlopEntry { + /// Unique identifier (UUID v4). + pub id: String, + /// Classification bucket. + pub bucket: SlopBucket, + /// How severe is this residue? + pub severity: SlopSeverity, + /// How confident is the assessment? + pub confidence: SlopConfidence, + /// Who owns cleaning this up (person, team, or "auto"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, + /// Source file paths, URLs, or line references. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub source_links: Vec, + /// Short title (one line). + pub title: String, + /// Detailed description. + pub description: String, + /// Current lifecycle status. + pub status: SlopEntryStatus, + /// Suggested cleanup action. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleanup_recommendation: Option, + /// ISO 8601 creation timestamp. + pub created_at: String, + /// ISO 8601 last-updated timestamp. + pub updated_at: String, + /// Optional linked durable task id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task_id: Option, + /// Optional linked thread id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thread_id: Option, +} + +impl SlopEntry { + pub fn new( + bucket: SlopBucket, + severity: SlopSeverity, + confidence: SlopConfidence, + title: String, + description: String, + ) -> Self { + let now = chrono::Utc::now().to_rfc3339(); + Self { + id: Uuid::new_v4().to_string(), + bucket, + severity, + confidence, + owner: None, + source_links: Vec::new(), + title, + description, + status: SlopEntryStatus::Open, + cleanup_recommendation: None, + created_at: now.clone(), + updated_at: now, + task_id: None, + thread_id: None, + } + } +} + +// ── Query filter ─────────────────────────────────────────────────────────── + +/// Filter for querying ledger entries. +#[derive(Debug, Clone, Default)] +pub struct SlopLedgerFilter { + pub bucket: Option, + pub severity: Option, + pub status: Option, + pub search: Option, // fuzzy match title + description + pub limit: Option, +} + +// ── Ledger (collection + persistence) ────────────────────────────────────── + +/// The slop ledger — a collection of entries with JSON file persistence. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SlopLedger { + entries: Vec, + #[serde(skip)] + ledger_path: PathBuf, +} + +impl SlopLedger { + /// Resolve the default ledger path. + pub fn default_path() -> io::Result { + codewhale_config::resolve_state_dir("slop_ledger") + .map(|p| p.join("slop_ledger.json")) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e)) + } + + /// Load ledger from the default path, returning an empty ledger if the + /// file doesn't exist. + pub fn load() -> io::Result { + let path = Self::default_path()?; + Self::load_at(&path) + } + + /// Load ledger from a specific path. + pub fn load_at(path: &std::path::Path) -> io::Result { + if !path.exists() { + return Ok(Self { + entries: Vec::new(), + ledger_path: path.to_path_buf(), + }); + } + let data = fs::read_to_string(path)?; + let mut ledger: SlopLedger = serde_json::from_str(&data).unwrap_or_default(); + ledger.ledger_path = path.to_path_buf(); + Ok(ledger) + } + + /// Persist the ledger to disk. + pub fn save(&self) -> io::Result<()> { + if let Some(parent) = self.ledger_path.parent() { + fs::create_dir_all(parent)?; + } + let data = serde_json::to_string_pretty(self).map_err(|e| { + io::Error::new(io::ErrorKind::Other, format!("serialization error: {e}")) + })?; + fs::write(&self.ledger_path, data) + } + + /// Append one or more entries and save. + pub fn append(&mut self, entries: Vec) -> &[SlopEntry] { + let start = self.entries.len(); + self.entries.extend(entries); + &self.entries[start..] + } + + /// Return the total number of entries. + #[must_use] + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether the ledger is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Query entries matching the filter. + pub fn query(&self, filter: &SlopLedgerFilter) -> Vec<&SlopEntry> { + let mut results: Vec<&SlopEntry> = self + .entries + .iter() + .filter(|e| { + if let Some(bucket) = &filter.bucket { + if e.bucket != *bucket { + return false; + } + } + if let Some(severity) = &filter.severity { + if e.severity != *severity { + return false; + } + } + if let Some(status) = &filter.status { + if e.status != *status { + return false; + } + } + if let Some(search) = &filter.search { + let q = search.to_lowercase(); + if !e.title.to_lowercase().contains(&q) + && !e.description.to_lowercase().contains(&q) + { + return false; + } + } + true + }) + .collect(); + + if let Some(limit) = filter.limit { + results.truncate(limit); + } + results + } + + /// Find an entry by id. + pub fn find_mut(&mut self, id: &str) -> Option<&mut SlopEntry> { + self.entries.iter_mut().find(|e| e.id == id) + } + + /// Update an entry's status (and optionally other fields) and save. + pub fn update_status( + &mut self, + id: &str, + status: SlopEntryStatus, + cleanup_recommendation: Option, + ) -> io::Result> { + let entry = match self.find_mut(id) { + Some(e) => e, + None => return Ok(None), + }; + entry.status = status; + entry.updated_at = chrono::Utc::now().to_rfc3339(); + if let Some(rec) = cleanup_recommendation { + entry.cleanup_recommendation = Some(rec); + } + self.save()?; + // Return a shared ref to the updated entry + Ok(self.entries.iter().find(|e| e.id == id)) + } + + /// Export all entries as a Markdown string suitable for handoff or + /// GitHub issue body. + pub fn export_markdown(&self, title: Option<&str>, filter: Option<&SlopLedgerFilter>) -> String { + let entries: Vec<&SlopEntry> = match filter { + Some(f) => self.query(f), + None => self.entries.iter().collect(), + }; + + let heading = title.unwrap_or("Slop Ledger Export"); + let mut out = format!("# {heading}\n\n"); + out.push_str(&format!( + "_Generated at {} — {} entries_\n\n", + chrono::Utc::now().format("%Y-%m-%d %H:%M UTC").to_string(), + entries.len() + )); + + if entries.is_empty() { + out.push_str("_(no entries)_\n"); + return out; + } + + // Group by bucket + use std::collections::BTreeMap; + let mut by_bucket: BTreeMap<&str, Vec<&&SlopEntry>> = BTreeMap::new(); + for e in &entries { + by_bucket.entry(e.bucket.as_str()).or_default().push(e); + } + + for (bucket_name, bucket_entries) in &by_bucket { + out.push_str(&format!("## {bucket_name}\n\n")); + out.push_str("| ID | Severity | Confidence | Status | Title | Source |\n"); + out.push_str("|---|---|---|---|---|---|\n"); + for e in bucket_entries { + let source = e.source_links.first().map(|s| s.as_str()).unwrap_or("-"); + let title = if e.title.len() > 60 { + format!("{}…", &e.title[..57]) + } else { + e.title.clone() + }; + out.push_str(&format!( + "| {} | {:?} | {:?} | {:?} | {title} | {source} |\n", + &e.id[..8], + e.severity, + e.confidence, + e.status + )); + } + out.push('\n'); + + // Detailed entries + for e in bucket_entries { + out.push_str(&format!( + "### {} — {}\n\n", + &e.id[..8], + e.title + )); + out.push_str(&format!("- **Severity**: {:?}\n", e.severity)); + out.push_str(&format!("- **Confidence**: {:?}\n", e.confidence)); + out.push_str(&format!("- **Status**: {:?}\n", e.status)); + if let Some(ref owner) = e.owner { + out.push_str(&format!("- **Owner**: {owner}\n")); + } + if !e.source_links.is_empty() { + out.push_str("- **Sources**:\n"); + for link in &e.source_links { + out.push_str(&format!(" - {link}\n")); + } + } + out.push_str(&format!("\n{}\n", e.description)); + if let Some(ref rec) = e.cleanup_recommendation { + out.push_str(&format!("\n**Cleanup**: {rec}\n")); + } + out.push_str("\n---\n\n"); + } + } + + out + } + + /// Summary counts by bucket and status — useful for quick display. + pub fn summary(&self) -> String { + use std::collections::BTreeMap; + let mut by_bucket: BTreeMap<&str, usize> = BTreeMap::new(); + let mut open_count = 0usize; + let mut resolved_count = 0usize; + let mut accepted_count = 0usize; + + for e in &self.entries { + *by_bucket.entry(e.bucket.as_str()).or_default() += 1; + match e.status { + SlopEntryStatus::Resolved => resolved_count += 1, + SlopEntryStatus::Accepted | SlopEntryStatus::WontFix => accepted_count += 1, + _ => open_count += 1, + } + } + + let mut out = format!( + "Slop Ledger: {} total | {} open | {} resolved | {} accepted\n", + self.entries.len(), + open_count, + resolved_count, + accepted_count + ); + for (bucket, count) in &by_bucket { + out.push_str(&format!(" {bucket}: {count}\n")); + } + out + } +} + +// ── Tools ────────────────────────────────────────────────────────────────── + +/// `slop_ledger_append` — append one or more entries to the slop ledger. +pub struct SlopLedgerAppendTool; + +#[async_trait] +impl ToolSpec for SlopLedgerAppendTool { + fn name(&self) -> &'static str { + "slop_ledger_append" + } + + fn description(&self) -> &'static str { + "Append one or more entries to the slop ledger — a durable record of \ + unresolved architectural residue (compatibility shims, unmigrated \ + callers, duplicate concepts, stale docs/tests, suspected dead code, \ + tool gaps, etc.). Use this when you complete a task and notice \ + residue that should be tracked for future cleanup. Each entry needs \ + a bucket, severity, confidence, title, and description." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "entries": { + "type": "array", + "description": "One or more slop entries to append.", + "items": { + "type": "object", + "properties": { + "bucket": { + "type": "string", + "description": "One of: retained_compatibility, unmigrated_callers, duplicate_concepts, naming_drift, stale_docs, stale_tests, suspected_dead_code, unverified_public_behavior, tool_gaps, accepted_debt" + }, + "severity": { + "type": "string", + "description": "critical | high | medium | low | info" + }, + "confidence": { + "type": "string", + "description": "certain | high | medium | low" + }, + "title": { + "type": "string", + "description": "Short title (one line)" + }, + "description": { + "type": "string", + "description": "Detailed description of the residue" + }, + "owner": { + "type": "string", + "description": "Optional: who should clean this up?" + }, + "source_links": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional: file paths or URLs" + } + }, + "required": ["bucket", "severity", "confidence", "title", "description"] + } + } + }, + "required": ["entries"] + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::WritesFiles] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + async fn execute(&self, input: Value, context: &ToolContext) -> Result { + let entries_val = input + .get("entries") + .and_then(|v| v.as_array()) + .ok_or_else(|| ToolError::invalid_input("'entries' must be a non-empty array"))?; + + let mut ledger = SlopLedger::load().map_err(|e| { + ToolError::execution_failed(format!("failed to load slop ledger: {e}")) + })?; + + let mut appended = Vec::new(); + for entry_val in entries_val { + let bucket_str = required_str(entry_val, "bucket")?; + let bucket = SlopBucket::from_str(bucket_str).ok_or_else(|| { + ToolError::invalid_input(format!("unknown bucket: '{bucket_str}'")) + })?; + + let severity = SlopSeverity::from_str(required_str(entry_val, "severity")?).ok_or_else(|| { + ToolError::invalid_input("invalid severity (use critical|high|medium|low|info)") + })?; + + let confidence = SlopConfidence::from_str(required_str(entry_val, "confidence")?).ok_or_else(|| { + ToolError::invalid_input("invalid confidence (use certain|high|medium|low)") + })?; + + let title = required_str(entry_val, "title")?.to_string(); + let description = required_str(entry_val, "description")?.to_string(); + + let mut entry = SlopEntry::new(bucket, severity, confidence, title, description); + + if let Some(owner) = entry_val.get("owner").and_then(|v| v.as_str()) { + entry.owner = Some(owner.to_string()); + } + if let Some(links) = entry_val.get("source_links").and_then(|v| v.as_array()) { + entry.source_links = links + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + } + + // Attach active task/thread context if available + if let Some(ref task_id) = context.runtime.active_task_id { + entry.task_id = Some(task_id.clone()); + } + if let Some(ref thread_id) = context.runtime.active_thread_id { + entry.thread_id = Some(thread_id.clone()); + } + + appended.push(entry); + } + + let saved = ledger.append(appended); + ledger.save().map_err(|e| { + ToolError::execution_failed(format!("failed to save slop ledger: {e}")) + })?; + + let ids: Vec<&str> = saved.iter().map(|e| e.id.as_str()).collect(); + Ok(ToolResult::success(format!( + "Appended {} slop ledger entr{}: {}", + saved.len(), + if saved.len() == 1 { "y" } else { "ies" }, + ids.iter().map(|id| &id[..8]).collect::>().join(", ") + ))) + } +} + +/// `slop_ledger_query` — query the slop ledger. +pub struct SlopLedgerQueryTool; + +#[async_trait] +impl ToolSpec for SlopLedgerQueryTool { + fn name(&self) -> &'static str { + "slop_ledger_query" + } + + fn description(&self) -> &'static str { + "Query the slop ledger for unresolved architectural residue. \ + Filter by bucket, severity, status, or text search." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "bucket": { + "type": "string", + "description": "Optional: filter by bucket" + }, + "severity": { + "type": "string", + "description": "Optional: filter by severity" + }, + "status": { + "type": "string", + "description": "Optional: filter by status" + }, + "search": { + "type": "string", + "description": "Optional: fuzzy text search in title and description" + }, + "limit": { + "type": "integer", + "description": "Optional: max results (default 50)" + } + } + }) + } + + fn capabilities(&self) -> Vec { + vec![] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + let filter = SlopLedgerFilter { + bucket: input + .get("bucket") + .and_then(|v| v.as_str()) + .and_then(SlopBucket::from_str), + severity: input + .get("severity") + .and_then(|v| v.as_str()) + .and_then(SlopSeverity::from_str), + status: input + .get("status") + .and_then(|v| v.as_str()) + .and_then(SlopEntryStatus::from_str), + search: input.get("search").and_then(|v| v.as_str()).map(String::from), + limit: input + .get("limit") + .and_then(|v| v.as_u64()) + .map(|n| n as usize) + .or(Some(50)), + }; + + let ledger = SlopLedger::load().map_err(|e| { + ToolError::execution_failed(format!("failed to load slop ledger: {e}")) + })?; + + if ledger.is_empty() { + return Ok(ToolResult::success("Slop ledger is empty.")); + } + + let results = ledger.query(&filter); + let mut out = format!("Found {} matching slop ledger entries:\n\n", results.len()); + for entry in &results { + out.push_str(&format!( + "- [{}] **{}** ({:?} | {:?} | {:?}) — {}\n", + &entry.id[..8], + entry.bucket.as_str(), + entry.severity, + entry.confidence, + entry.status, + entry.title + )); + if let Some(ref desc) = entry.description.lines().next() { + out.push_str(&format!(" {desc}\n")); + } + } + Ok(ToolResult::success(out)) + } +} + +/// `slop_ledger_update` — update an entry's status. +pub struct SlopLedgerUpdateTool; + +#[async_trait] +impl ToolSpec for SlopLedgerUpdateTool { + fn name(&self) -> &'static str { + "slop_ledger_update" + } + + fn description(&self) -> &'static str { + "Update a slop ledger entry's status (e.g., mark as resolved, accepted, or in-progress)." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The entry ID (or prefix) to update" + }, + "status": { + "type": "string", + "description": "New status: open | in_progress | resolved | accepted | wontfix" + }, + "cleanup_recommendation": { + "type": "string", + "description": "Optional: cleanup notes when resolving or accepting" + } + }, + "required": ["id", "status"] + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::WritesFiles] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + let id = required_str(&input, "id")?; + let status = SlopEntryStatus::from_str(required_str(&input, "status")?).ok_or_else(|| { + ToolError::invalid_input( + "invalid status (use open|in_progress|resolved|accepted|wontfix)", + ) + })?; + + let cleanup = input + .get("cleanup_recommendation") + .and_then(|v| v.as_str()) + .map(String::from); + + let mut ledger = SlopLedger::load().map_err(|e| { + ToolError::execution_failed(format!("failed to load slop ledger: {e}")) + })?; + + match ledger.update_status(id, status, cleanup) { + Ok(Some(entry)) => Ok(ToolResult::success(format!( + "Updated slop ledger entry {} ({}) → {:?}", + &entry.id[..8], + entry.title, + entry.status + ))), + Ok(None) => Ok(ToolResult::success(format!( + "No slop ledger entry found matching '{id}'. Use slop_ledger_query to list entries." + ))), + Err(e) => Err(ToolError::execution_failed(format!( + "failed to update slop ledger: {e}" + ))), + } + } +} + +/// `slop_ledger_export` — export ledger as Markdown. +pub struct SlopLedgerExportTool; + +#[async_trait] +impl ToolSpec for SlopLedgerExportTool { + fn name(&self) -> &'static str { + "slop_ledger_export" + } + + fn description(&self) -> &'static str { + "Export the slop ledger as a Markdown report. Use this for handoffs, \ + compaction relays, or GitHub issue creation. The output is suitable \ + for pasting directly into a GitHub issue body." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Optional: report title (default 'Slop Ledger Export')" + }, + "bucket": { + "type": "string", + "description": "Optional: filter by bucket" + }, + "severity": { + "type": "string", + "description": "Optional: filter by severity" + }, + "status": { + "type": "string", + "description": "Optional: filter by status" + } + } + }) + } + + fn capabilities(&self) -> Vec { + vec![] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + let title = input.get("title").and_then(|v| v.as_str()); + + let filter = if input.get("bucket").is_some() + || input.get("severity").is_some() + || input.get("status").is_some() + { + Some(SlopLedgerFilter { + bucket: input + .get("bucket") + .and_then(|v| v.as_str()) + .and_then(SlopBucket::from_str), + severity: input + .get("severity") + .and_then(|v| v.as_str()) + .and_then(SlopSeverity::from_str), + status: input + .get("status") + .and_then(|v| v.as_str()) + .and_then(SlopEntryStatus::from_str), + ..Default::default() + }) + } else { + None + }; + + let ledger = SlopLedger::load().map_err(|e| { + ToolError::execution_failed(format!("failed to load slop ledger: {e}")) + })?; + + let markdown = ledger.export_markdown(title, filter.as_ref()); + Ok(ToolResult::success(markdown)) + } +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn temp_ledger() -> (TempDir, SlopLedger) { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("slop_ledger.json"); + let ledger = SlopLedger { + entries: Vec::new(), + ledger_path: path, + }; + (tmp, ledger) + } + + #[test] + fn bucket_roundtrip() { + for bucket in SlopBucket::all_buckets() { + let s = bucket.as_str(); + let parsed = SlopBucket::from_str(s); + assert_eq!(parsed, Some(*bucket), "roundtrip failed for {s}"); + } + } + + #[test] + fn append_and_save_load() { + let (_tmp, mut ledger) = temp_ledger(); + + let entry = SlopEntry::new( + SlopBucket::StaleDocs, + SlopSeverity::Medium, + SlopConfidence::High, + "README is outdated".into(), + "The README still references v0.7 APIs.".into(), + ); + + ledger.append(vec![entry]); + assert_eq!(ledger.len(), 1); + ledger.save().unwrap(); + + let loaded = SlopLedger::load_at(&ledger.ledger_path).unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded.entries[0].title, "README is outdated"); + } + + #[test] + fn query_by_bucket() { + let (_tmp, mut ledger) = temp_ledger(); + + ledger.append(vec![ + SlopEntry::new( + SlopBucket::StaleDocs, + SlopSeverity::Low, + SlopConfidence::Certain, + "doc A".into(), + "desc A".into(), + ), + SlopEntry::new( + SlopBucket::ToolGaps, + SlopSeverity::High, + SlopConfidence::Medium, + "gap B".into(), + "desc B".into(), + ), + ]); + + let filter = SlopLedgerFilter { + bucket: Some(SlopBucket::StaleDocs), + ..Default::default() + }; + let results = ledger.query(&filter); + assert_eq!(results.len(), 1); + assert_eq!(results[0].title, "doc A"); + } + + #[test] + fn query_by_search() { + let (_tmp, mut ledger) = temp_ledger(); + + ledger.append(vec![SlopEntry::new( + SlopBucket::SuspectedDeadCode, + SlopSeverity::Medium, + SlopConfidence::Low, + "dead legacy handler".into(), + "The legacy handler in src/old.rs appears unused.".into(), + )]); + + let filter = SlopLedgerFilter { + search: Some("legacy".into()), + ..Default::default() + }; + let results = ledger.query(&filter); + assert_eq!(results.len(), 1); + } + + #[test] + fn update_status() { + let (_tmp, mut ledger) = temp_ledger(); + + let entry = SlopEntry::new( + SlopBucket::NamingDrift, + SlopSeverity::Low, + SlopConfidence::High, + "naming issue".into(), + "desc".into(), + ); + let id = entry.id.clone(); + ledger.append(vec![entry]); + ledger.save().unwrap(); + + let result = ledger + .update_status(&id, SlopEntryStatus::Resolved, Some("Renamed in #1234".into())) + .unwrap(); + assert!(result.is_some()); + + let loaded = SlopLedger::load_at(&ledger.ledger_path).unwrap(); + assert_eq!(loaded.entries[0].status, SlopEntryStatus::Resolved); + assert_eq!( + loaded.entries[0].cleanup_recommendation, + Some("Renamed in #1234".into()) + ); + } + + #[test] + fn export_markdown() { + let (_tmp, mut ledger) = temp_ledger(); + + let mut entry = SlopEntry::new( + SlopBucket::StaleDocs, + SlopSeverity::Medium, + SlopConfidence::High, + "Outdated README".into(), + "The README references removed flags.".into(), + ); + entry.source_links = vec!["README.md:42".into()]; + ledger.append(vec![entry]); + + let md = ledger.export_markdown(Some("Test Export"), None); + assert!(md.contains("Test Export")); + assert!(md.contains("stale_docs")); + assert!(md.contains("Outdated README")); + assert!(md.contains("README.md:42")); + } + + #[test] + fn empty_ledger_loads() { + let (_tmp, ledger) = temp_ledger(); + assert!(ledger.is_empty()); + assert_eq!(ledger.len(), 0); + } + + #[test] + fn summary_counts() { + let (_tmp, mut ledger) = temp_ledger(); + + let mut e1 = SlopEntry::new( + SlopBucket::StaleDocs, + SlopSeverity::Medium, + SlopConfidence::High, + "doc".into(), + "desc".into(), + ); + e1.status = SlopEntryStatus::Open; + + let mut e2 = SlopEntry::new( + SlopBucket::ToolGaps, + SlopSeverity::High, + SlopConfidence::Certain, + "gap".into(), + "desc".into(), + ); + e2.status = SlopEntryStatus::Resolved; + + let mut e3 = SlopEntry::new( + SlopBucket::AcceptedDebt, + SlopSeverity::Low, + SlopConfidence::Medium, + "debt".into(), + "desc".into(), + ); + e3.status = SlopEntryStatus::Accepted; + + ledger.append(vec![e1, e2, e3]); + + let summary = ledger.summary(); + assert!(summary.contains("3 total")); + assert!(summary.contains("stale_docs: 1")); + assert!(summary.contains("tool_gaps: 1")); + assert!(summary.contains("accepted_debt: 1")); + } +} diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index 5254de70..a3994e33 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -720,6 +720,22 @@ impl ToolRegistryBuilder { self.with_tool(Arc::new(RememberTool)) } + /// Include the slop ledger tools (#2127) — durable tracking of + /// unresolved architectural residue: append, query, update, export. + /// Registered unconditionally; the ledger JSON file is auto-created + /// on first append. + #[must_use] + pub fn with_slop_ledger_tools(self) -> Self { + use crate::slop_ledger::{ + SlopLedgerAppendTool, SlopLedgerExportTool, SlopLedgerQueryTool, + SlopLedgerUpdateTool, + }; + self.with_tool(Arc::new(SlopLedgerAppendTool)) + .with_tool(Arc::new(SlopLedgerQueryTool)) + .with_tool(Arc::new(SlopLedgerUpdateTool)) + .with_tool(Arc::new(SlopLedgerExportTool)) + } + /// Include the `notify` tool — model-callable desktop notification /// (#1322). Routes through the existing `tui::notifications` OSC 9 / /// BEL pipeline so the user's `[notifications].method` config is From 3cfb26539ac894e7db9bb017c907c1d0226765de Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Tue, 26 May 2026 12:46:30 +0800 Subject: [PATCH 023/283] fix: resolve borrow-checker error in slop_ledger append tool --- crates/tui/src/slop_ledger.rs | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/crates/tui/src/slop_ledger.rs b/crates/tui/src/slop_ledger.rs index 4aca4509..23eae9d9 100644 --- a/crates/tui/src/slop_ledger.rs +++ b/crates/tui/src/slop_ledger.rs @@ -297,11 +297,12 @@ impl SlopLedger { fs::write(&self.ledger_path, data) } - /// Append one or more entries and save. - pub fn append(&mut self, entries: Vec) -> &[SlopEntry] { - let start = self.entries.len(); + /// Append one or more entries. Returns the new entry count and + /// the short ids of the appended entries (first 8 chars). + pub fn append(&mut self, entries: Vec) -> (usize, Vec) { + let ids: Vec = entries.iter().map(|e| e.id[..8].to_string()).collect(); self.entries.extend(entries); - &self.entries[start..] + (self.entries.len(), ids) } /// Return the total number of entries. @@ -618,17 +619,19 @@ impl ToolSpec for SlopLedgerAppendTool { appended.push(entry); } - let saved = ledger.append(appended); + let (total, ids) = ledger.append(appended); + let appended_count = ids.len(); + ledger.save().map_err(|e| { ToolError::execution_failed(format!("failed to save slop ledger: {e}")) })?; - let ids: Vec<&str> = saved.iter().map(|e| e.id.as_str()).collect(); Ok(ToolResult::success(format!( - "Appended {} slop ledger entr{}: {}", - saved.len(), - if saved.len() == 1 { "y" } else { "ies" }, - ids.iter().map(|id| &id[..8]).collect::>().join(", ") + "Appended {} slop ledger entr{} ({} total): {}", + appended_count, + if appended_count == 1 { "y" } else { "ies" }, + total, + ids.join(", ") ))) } } @@ -929,7 +932,7 @@ mod tests { "The README still references v0.7 APIs.".into(), ); - ledger.append(vec![entry]); + let _ = ledger.append(vec![entry]); assert_eq!(ledger.len(), 1); ledger.save().unwrap(); @@ -942,7 +945,7 @@ mod tests { fn query_by_bucket() { let (_tmp, mut ledger) = temp_ledger(); - ledger.append(vec![ + let _ = ledger.append(vec![ SlopEntry::new( SlopBucket::StaleDocs, SlopSeverity::Low, @@ -972,7 +975,7 @@ mod tests { fn query_by_search() { let (_tmp, mut ledger) = temp_ledger(); - ledger.append(vec![SlopEntry::new( + let _ = ledger.append(vec![SlopEntry::new( SlopBucket::SuspectedDeadCode, SlopSeverity::Medium, SlopConfidence::Low, @@ -1000,7 +1003,7 @@ mod tests { "desc".into(), ); let id = entry.id.clone(); - ledger.append(vec![entry]); + let _ = ledger.append(vec![entry]); ledger.save().unwrap(); let result = ledger @@ -1028,7 +1031,7 @@ mod tests { "The README references removed flags.".into(), ); entry.source_links = vec!["README.md:42".into()]; - ledger.append(vec![entry]); + let _ = ledger.append(vec![entry]); let md = ledger.export_markdown(Some("Test Export"), None); assert!(md.contains("Test Export")); @@ -1075,7 +1078,7 @@ mod tests { ); e3.status = SlopEntryStatus::Accepted; - ledger.append(vec![e1, e2, e3]); + let _ = ledger.append(vec![e1, e2, e3]); let summary = ledger.summary(); assert!(summary.contains("3 total")); From 3e073992eb202e68f9472dae9a8c54c6498106a2 Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Tue, 26 May 2026 12:53:35 +0800 Subject: [PATCH 024/283] fix: use read-only slop ledger tools in plan mode --- COMMIT_MSG.md | 73 ++++++++++++++++++++++++ crates/tui/src/core/engine/tool_setup.rs | 11 +++- crates/tui/src/tools/registry.rs | 9 +++ 3 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 COMMIT_MSG.md diff --git a/COMMIT_MSG.md b/COMMIT_MSG.md new file mode 100644 index 00000000..d28ca129 --- /dev/null +++ b/COMMIT_MSG.md @@ -0,0 +1,73 @@ +# Commit Message — SlopLedger (#2127) + +## Summary + +Add a durable `SlopLedger` that makes invisible architectural residue +visible and queryable across agent sessions. + +Closes: https://github.com/Hmbown/CodeWhale/issues/2127 + +## Problem + +AI agents often leave behind invisible "slop" after a task: +compatibility shims, unmigrated callers, duplicated concepts, +naming drift, stale docs/tests, suspected dead code, and tool gaps. + +Currently these residues are untracked. The next agent rediscovers +them, amplifies them, or mistakes them for intended architecture. + +## Solution + +A persistent JSON-backed ledger (`~/.codewhale/slop_ledger/slop_ledger.json`) +with four model-callable tools and a `/slop` slash command. + +### Data Model + +- **10 classification buckets**: retained_compatibility, unmigrated_callers, + duplicate_concepts, naming_drift, stale_docs, stale_tests, + suspected_dead_code, unverified_public_behavior, tool_gaps, accepted_debt +- **Severity**: critical | high | medium | low | info +- **Confidence**: certain | high | medium | low +- **Status lifecycle**: open → in_progress → resolved | accepted | wontfix +- Each entry carries: owner, source links, title, description, + cleanup recommendation, timestamps, and optional task_id / thread_id + +### Tools (model-callable) + +| Tool | Description | +|---|---| +| `slop_ledger_append` | Append entries with bucket, severity, confidence, title, description | +| `slop_ledger_query` | Query with bucket/severity/status/text filters | +| `slop_ledger_update` | Update entry status | +| `slop_ledger_export` | Export as Markdown for handoffs / GitHub issues | + +### Slash Command + +- `/slop` — print summary +- `/slop query` — list entries +- `/slop export` — Markdown export +- Alias: `/canzha` + +### Files Changed + +| File | Change | +|---|---| +| `crates/tui/src/slop_ledger.rs` | **New** — 1089 lines | +| `crates/tui/src/main.rs` | +1: mod declaration | +| `crates/tui/src/tools/registry.rs` | +16: builder method | +| `crates/tui/src/core/engine/tool_setup.rs` | +1: registration | +| `crates/tui/src/commands/mod.rs` | +10: command + dispatch | +| `crates/tui/src/commands/config.rs` | +41: handler | + +### Tests + +8 unit tests: bucket roundtrip, save/load, query by bucket/search, +update status, markdown export, empty ledger, summary counts. + +## How to Test + +```bash +cargo test -p codewhale-tui -- slop_ledger +``` + +In TUI: `/slop`, `/slop query`, `/slop export` diff --git a/crates/tui/src/core/engine/tool_setup.rs b/crates/tui/src/core/engine/tool_setup.rs index 2f2845b0..c4da4156 100644 --- a/crates/tui/src/core/engine/tool_setup.rs +++ b/crates/tui/src/core/engine/tool_setup.rs @@ -63,8 +63,15 @@ impl Engine { .with_review_tool(self.deepseek_client.clone(), self.session.model.clone()) .with_user_input_tool() .with_parallel_tool() - .with_recall_archive_tool() - .with_slop_ledger_tools(); + .with_recall_archive_tool(); + + // SlopLedger: plan mode only gets read-only query + export, + // agent/yolo get the full set including append + update. + builder = if mode == AppMode::Plan { + builder.with_slop_ledger_read_only_tools() + } else { + builder.with_slop_ledger_tools() + }; if mode != AppMode::Plan { builder = builder diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index a3994e33..a4f453b4 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -736,6 +736,15 @@ impl ToolRegistryBuilder { .with_tool(Arc::new(SlopLedgerExportTool)) } + /// Read-only subset of slop ledger tools (#2127) for plan mode: + /// only query and export — no append or update. + #[must_use] + pub fn with_slop_ledger_read_only_tools(self) -> Self { + use crate::slop_ledger::{SlopLedgerExportTool, SlopLedgerQueryTool}; + self.with_tool(Arc::new(SlopLedgerQueryTool)) + .with_tool(Arc::new(SlopLedgerExportTool)) + } + /// Include the `notify` tool — model-callable desktop notification /// (#1322). Routes through the existing `tui::notifications` OSC 9 / /// BEL pipeline so the user's `[notifications].method` config is From d10743362e1059899ea7abde4797797286522262 Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Tue, 26 May 2026 12:55:29 +0800 Subject: [PATCH 025/283] chore: remove COMMIT_MSG.md from tracking --- COMMIT_MSG.md | 73 --------------------------------------------------- 1 file changed, 73 deletions(-) delete mode 100644 COMMIT_MSG.md diff --git a/COMMIT_MSG.md b/COMMIT_MSG.md deleted file mode 100644 index d28ca129..00000000 --- a/COMMIT_MSG.md +++ /dev/null @@ -1,73 +0,0 @@ -# Commit Message — SlopLedger (#2127) - -## Summary - -Add a durable `SlopLedger` that makes invisible architectural residue -visible and queryable across agent sessions. - -Closes: https://github.com/Hmbown/CodeWhale/issues/2127 - -## Problem - -AI agents often leave behind invisible "slop" after a task: -compatibility shims, unmigrated callers, duplicated concepts, -naming drift, stale docs/tests, suspected dead code, and tool gaps. - -Currently these residues are untracked. The next agent rediscovers -them, amplifies them, or mistakes them for intended architecture. - -## Solution - -A persistent JSON-backed ledger (`~/.codewhale/slop_ledger/slop_ledger.json`) -with four model-callable tools and a `/slop` slash command. - -### Data Model - -- **10 classification buckets**: retained_compatibility, unmigrated_callers, - duplicate_concepts, naming_drift, stale_docs, stale_tests, - suspected_dead_code, unverified_public_behavior, tool_gaps, accepted_debt -- **Severity**: critical | high | medium | low | info -- **Confidence**: certain | high | medium | low -- **Status lifecycle**: open → in_progress → resolved | accepted | wontfix -- Each entry carries: owner, source links, title, description, - cleanup recommendation, timestamps, and optional task_id / thread_id - -### Tools (model-callable) - -| Tool | Description | -|---|---| -| `slop_ledger_append` | Append entries with bucket, severity, confidence, title, description | -| `slop_ledger_query` | Query with bucket/severity/status/text filters | -| `slop_ledger_update` | Update entry status | -| `slop_ledger_export` | Export as Markdown for handoffs / GitHub issues | - -### Slash Command - -- `/slop` — print summary -- `/slop query` — list entries -- `/slop export` — Markdown export -- Alias: `/canzha` - -### Files Changed - -| File | Change | -|---|---| -| `crates/tui/src/slop_ledger.rs` | **New** — 1089 lines | -| `crates/tui/src/main.rs` | +1: mod declaration | -| `crates/tui/src/tools/registry.rs` | +16: builder method | -| `crates/tui/src/core/engine/tool_setup.rs` | +1: registration | -| `crates/tui/src/commands/mod.rs` | +10: command + dispatch | -| `crates/tui/src/commands/config.rs` | +41: handler | - -### Tests - -8 unit tests: bucket roundtrip, save/load, query by bucket/search, -update status, markdown export, empty ledger, summary counts. - -## How to Test - -```bash -cargo test -p codewhale-tui -- slop_ledger -``` - -In TUI: `/slop`, `/slop query`, `/slop export` From f70007663f58e9ab9d7f9a6724258cc3337e6d27 Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Tue, 26 May 2026 13:06:50 +0800 Subject: [PATCH 026/283] fix: suppress dead_code warnings for CI -Dwarnings --- crates/tui/src/slop_ledger.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/tui/src/slop_ledger.rs b/crates/tui/src/slop_ledger.rs index 23eae9d9..9e0b1ed1 100644 --- a/crates/tui/src/slop_ledger.rs +++ b/crates/tui/src/slop_ledger.rs @@ -81,6 +81,7 @@ impl SlopBucket { } } + #[allow(dead_code)] pub fn all_buckets() -> &'static [SlopBucket] { &[ Self::RetainedCompatibility, @@ -307,6 +308,7 @@ impl SlopLedger { /// Return the total number of entries. #[must_use] + #[allow(dead_code)] pub fn len(&self) -> usize { self.entries.len() } From 8928e1dde44b4947a5e0f07ebff619812049fdb0 Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Tue, 26 May 2026 13:52:59 +0800 Subject: [PATCH 027/283] =?UTF-8?q?fix:=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20error=20propagation,=20prefix=20match,=20atomic=20w?= =?UTF-8?q?rite,=20UTF-8=20safe=20truncation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COMMIT_MSG.md | 73 ++++++++++++++++++++++ crates/tui/src/main.rs | 2 +- crates/tui/src/slop_ledger.rs | 102 ++++++++++++++++++------------- crates/tui/src/tools/registry.rs | 3 +- 4 files changed, 135 insertions(+), 45 deletions(-) create mode 100644 COMMIT_MSG.md diff --git a/COMMIT_MSG.md b/COMMIT_MSG.md new file mode 100644 index 00000000..d28ca129 --- /dev/null +++ b/COMMIT_MSG.md @@ -0,0 +1,73 @@ +# Commit Message — SlopLedger (#2127) + +## Summary + +Add a durable `SlopLedger` that makes invisible architectural residue +visible and queryable across agent sessions. + +Closes: https://github.com/Hmbown/CodeWhale/issues/2127 + +## Problem + +AI agents often leave behind invisible "slop" after a task: +compatibility shims, unmigrated callers, duplicated concepts, +naming drift, stale docs/tests, suspected dead code, and tool gaps. + +Currently these residues are untracked. The next agent rediscovers +them, amplifies them, or mistakes them for intended architecture. + +## Solution + +A persistent JSON-backed ledger (`~/.codewhale/slop_ledger/slop_ledger.json`) +with four model-callable tools and a `/slop` slash command. + +### Data Model + +- **10 classification buckets**: retained_compatibility, unmigrated_callers, + duplicate_concepts, naming_drift, stale_docs, stale_tests, + suspected_dead_code, unverified_public_behavior, tool_gaps, accepted_debt +- **Severity**: critical | high | medium | low | info +- **Confidence**: certain | high | medium | low +- **Status lifecycle**: open → in_progress → resolved | accepted | wontfix +- Each entry carries: owner, source links, title, description, + cleanup recommendation, timestamps, and optional task_id / thread_id + +### Tools (model-callable) + +| Tool | Description | +|---|---| +| `slop_ledger_append` | Append entries with bucket, severity, confidence, title, description | +| `slop_ledger_query` | Query with bucket/severity/status/text filters | +| `slop_ledger_update` | Update entry status | +| `slop_ledger_export` | Export as Markdown for handoffs / GitHub issues | + +### Slash Command + +- `/slop` — print summary +- `/slop query` — list entries +- `/slop export` — Markdown export +- Alias: `/canzha` + +### Files Changed + +| File | Change | +|---|---| +| `crates/tui/src/slop_ledger.rs` | **New** — 1089 lines | +| `crates/tui/src/main.rs` | +1: mod declaration | +| `crates/tui/src/tools/registry.rs` | +16: builder method | +| `crates/tui/src/core/engine/tool_setup.rs` | +1: registration | +| `crates/tui/src/commands/mod.rs` | +10: command + dispatch | +| `crates/tui/src/commands/config.rs` | +41: handler | + +### Tests + +8 unit tests: bucket roundtrip, save/load, query by bucket/search, +update status, markdown export, empty ledger, summary counts. + +## How to Test + +```bash +cargo test -p codewhale-tui -- slop_ledger +``` + +In TUI: `/slop`, `/slop query`, `/slop export` diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index ba082137..8136d744 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -65,11 +65,11 @@ mod session_manager; mod settings; mod skill_state; mod skills; +mod slop_ledger; mod snapshot; mod task_manager; #[cfg(test)] mod test_support; -mod slop_ledger; mod theme_qa_audit; mod tools; mod tui; diff --git a/crates/tui/src/slop_ledger.rs b/crates/tui/src/slop_ledger.rs index 9e0b1ed1..bc3a4f3a 100644 --- a/crates/tui/src/slop_ledger.rs +++ b/crates/tui/src/slop_ledger.rs @@ -282,7 +282,12 @@ impl SlopLedger { }); } let data = fs::read_to_string(path)?; - let mut ledger: SlopLedger = serde_json::from_str(&data).unwrap_or_default(); + let mut ledger: SlopLedger = serde_json::from_str(&data).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("failed to parse slop ledger JSON: {e}"), + ) + })?; ledger.ledger_path = path.to_path_buf(); Ok(ledger) } @@ -295,7 +300,7 @@ impl SlopLedger { let data = serde_json::to_string_pretty(self).map_err(|e| { io::Error::new(io::ErrorKind::Other, format!("serialization error: {e}")) })?; - fs::write(&self.ledger_path, data) + crate::utils::write_atomic(&self.ledger_path, data.as_bytes()) } /// Append one or more entries. Returns the new entry count and @@ -360,7 +365,7 @@ impl SlopLedger { /// Find an entry by id. pub fn find_mut(&mut self, id: &str) -> Option<&mut SlopEntry> { - self.entries.iter_mut().find(|e| e.id == id) + self.entries.iter_mut().find(|e| e.id.starts_with(id)) } /// Update an entry's status (and optionally other fields) and save. @@ -386,7 +391,11 @@ impl SlopLedger { /// Export all entries as a Markdown string suitable for handoff or /// GitHub issue body. - pub fn export_markdown(&self, title: Option<&str>, filter: Option<&SlopLedgerFilter>) -> String { + pub fn export_markdown( + &self, + title: Option<&str>, + filter: Option<&SlopLedgerFilter>, + ) -> String { let entries: Vec<&SlopEntry> = match filter { Some(f) => self.query(f), None => self.entries.iter().collect(), @@ -418,11 +427,7 @@ impl SlopLedger { out.push_str("|---|---|---|---|---|---|\n"); for e in bucket_entries { let source = e.source_links.first().map(|s| s.as_str()).unwrap_or("-"); - let title = if e.title.len() > 60 { - format!("{}…", &e.title[..57]) - } else { - e.title.clone() - }; + let title = truncate_str(&e.title, 60); out.push_str(&format!( "| {} | {:?} | {:?} | {:?} | {title} | {source} |\n", &e.id[..8], @@ -435,11 +440,7 @@ impl SlopLedger { // Detailed entries for e in bucket_entries { - out.push_str(&format!( - "### {} — {}\n\n", - &e.id[..8], - e.title - )); + out.push_str(&format!("### {} — {}\n\n", &e.id[..8], e.title)); out.push_str(&format!("- **Severity**: {:?}\n", e.severity)); out.push_str(&format!("- **Confidence**: {:?}\n", e.confidence)); out.push_str(&format!("- **Status**: {:?}\n", e.status)); @@ -576,9 +577,8 @@ impl ToolSpec for SlopLedgerAppendTool { .and_then(|v| v.as_array()) .ok_or_else(|| ToolError::invalid_input("'entries' must be a non-empty array"))?; - let mut ledger = SlopLedger::load().map_err(|e| { - ToolError::execution_failed(format!("failed to load slop ledger: {e}")) - })?; + let mut ledger = SlopLedger::load() + .map_err(|e| ToolError::execution_failed(format!("failed to load slop ledger: {e}")))?; let mut appended = Vec::new(); for entry_val in entries_val { @@ -587,13 +587,15 @@ impl ToolSpec for SlopLedgerAppendTool { ToolError::invalid_input(format!("unknown bucket: '{bucket_str}'")) })?; - let severity = SlopSeverity::from_str(required_str(entry_val, "severity")?).ok_or_else(|| { - ToolError::invalid_input("invalid severity (use critical|high|medium|low|info)") - })?; + let severity = SlopSeverity::from_str(required_str(entry_val, "severity")?) + .ok_or_else(|| { + ToolError::invalid_input("invalid severity (use critical|high|medium|low|info)") + })?; - let confidence = SlopConfidence::from_str(required_str(entry_val, "confidence")?).ok_or_else(|| { - ToolError::invalid_input("invalid confidence (use certain|high|medium|low)") - })?; + let confidence = SlopConfidence::from_str(required_str(entry_val, "confidence")?) + .ok_or_else(|| { + ToolError::invalid_input("invalid confidence (use certain|high|medium|low)") + })?; let title = required_str(entry_val, "title")?.to_string(); let description = required_str(entry_val, "description")?.to_string(); @@ -624,9 +626,9 @@ impl ToolSpec for SlopLedgerAppendTool { let (total, ids) = ledger.append(appended); let appended_count = ids.len(); - ledger.save().map_err(|e| { - ToolError::execution_failed(format!("failed to save slop ledger: {e}")) - })?; + ledger + .save() + .map_err(|e| ToolError::execution_failed(format!("failed to save slop ledger: {e}")))?; Ok(ToolResult::success(format!( "Appended {} slop ledger entr{} ({} total): {}", @@ -702,7 +704,10 @@ impl ToolSpec for SlopLedgerQueryTool { .get("status") .and_then(|v| v.as_str()) .and_then(SlopEntryStatus::from_str), - search: input.get("search").and_then(|v| v.as_str()).map(String::from), + search: input + .get("search") + .and_then(|v| v.as_str()) + .map(String::from), limit: input .get("limit") .and_then(|v| v.as_u64()) @@ -710,9 +715,8 @@ impl ToolSpec for SlopLedgerQueryTool { .or(Some(50)), }; - let ledger = SlopLedger::load().map_err(|e| { - ToolError::execution_failed(format!("failed to load slop ledger: {e}")) - })?; + let ledger = SlopLedger::load() + .map_err(|e| ToolError::execution_failed(format!("failed to load slop ledger: {e}")))?; if ledger.is_empty() { return Ok(ToolResult::success("Slop ledger is empty.")); @@ -782,20 +786,20 @@ impl ToolSpec for SlopLedgerUpdateTool { async fn execute(&self, input: Value, _context: &ToolContext) -> Result { let id = required_str(&input, "id")?; - let status = SlopEntryStatus::from_str(required_str(&input, "status")?).ok_or_else(|| { - ToolError::invalid_input( - "invalid status (use open|in_progress|resolved|accepted|wontfix)", - ) - })?; + let status = + SlopEntryStatus::from_str(required_str(&input, "status")?).ok_or_else(|| { + ToolError::invalid_input( + "invalid status (use open|in_progress|resolved|accepted|wontfix)", + ) + })?; let cleanup = input .get("cleanup_recommendation") .and_then(|v| v.as_str()) .map(String::from); - let mut ledger = SlopLedger::load().map_err(|e| { - ToolError::execution_failed(format!("failed to load slop ledger: {e}")) - })?; + let mut ledger = SlopLedger::load() + .map_err(|e| ToolError::execution_failed(format!("failed to load slop ledger: {e}")))?; match ledger.update_status(id, status, cleanup) { Ok(Some(entry)) => Ok(ToolResult::success(format!( @@ -887,15 +891,25 @@ impl ToolSpec for SlopLedgerExportTool { None }; - let ledger = SlopLedger::load().map_err(|e| { - ToolError::execution_failed(format!("failed to load slop ledger: {e}")) - })?; + let ledger = SlopLedger::load() + .map_err(|e| ToolError::execution_failed(format!("failed to load slop ledger: {e}")))?; let markdown = ledger.export_markdown(title, filter.as_ref()); Ok(ToolResult::success(markdown)) } } +/// Truncate a UTF-8 string to at most `max_chars` characters, appending '…' +/// when truncation occurs. Operates on char boundaries — never panics on +/// multi-byte characters. +fn truncate_str(s: &str, max_chars: usize) -> String { + if s.chars().count() <= max_chars { + return s.to_string(); + } + let truncated: String = s.chars().take(max_chars.saturating_sub(1)).collect(); + format!("{truncated}…") +} + // ── Tests ────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -1009,7 +1023,11 @@ mod tests { ledger.save().unwrap(); let result = ledger - .update_status(&id, SlopEntryStatus::Resolved, Some("Renamed in #1234".into())) + .update_status( + &id, + SlopEntryStatus::Resolved, + Some("Renamed in #1234".into()), + ) .unwrap(); assert!(result.is_some()); diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index a4f453b4..04f62003 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -727,8 +727,7 @@ impl ToolRegistryBuilder { #[must_use] pub fn with_slop_ledger_tools(self) -> Self { use crate::slop_ledger::{ - SlopLedgerAppendTool, SlopLedgerExportTool, SlopLedgerQueryTool, - SlopLedgerUpdateTool, + SlopLedgerAppendTool, SlopLedgerExportTool, SlopLedgerQueryTool, SlopLedgerUpdateTool, }; self.with_tool(Arc::new(SlopLedgerAppendTool)) .with_tool(Arc::new(SlopLedgerQueryTool)) From 9c5bf7dadf51d68613b54ded2e865e1fc3ce6a0a Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Tue, 26 May 2026 13:57:43 +0800 Subject: [PATCH 028/283] chore: remove COMMIT_MSG.md --- COMMIT_MSG.md | 73 --------------------------------------------------- 1 file changed, 73 deletions(-) delete mode 100644 COMMIT_MSG.md diff --git a/COMMIT_MSG.md b/COMMIT_MSG.md deleted file mode 100644 index d28ca129..00000000 --- a/COMMIT_MSG.md +++ /dev/null @@ -1,73 +0,0 @@ -# Commit Message — SlopLedger (#2127) - -## Summary - -Add a durable `SlopLedger` that makes invisible architectural residue -visible and queryable across agent sessions. - -Closes: https://github.com/Hmbown/CodeWhale/issues/2127 - -## Problem - -AI agents often leave behind invisible "slop" after a task: -compatibility shims, unmigrated callers, duplicated concepts, -naming drift, stale docs/tests, suspected dead code, and tool gaps. - -Currently these residues are untracked. The next agent rediscovers -them, amplifies them, or mistakes them for intended architecture. - -## Solution - -A persistent JSON-backed ledger (`~/.codewhale/slop_ledger/slop_ledger.json`) -with four model-callable tools and a `/slop` slash command. - -### Data Model - -- **10 classification buckets**: retained_compatibility, unmigrated_callers, - duplicate_concepts, naming_drift, stale_docs, stale_tests, - suspected_dead_code, unverified_public_behavior, tool_gaps, accepted_debt -- **Severity**: critical | high | medium | low | info -- **Confidence**: certain | high | medium | low -- **Status lifecycle**: open → in_progress → resolved | accepted | wontfix -- Each entry carries: owner, source links, title, description, - cleanup recommendation, timestamps, and optional task_id / thread_id - -### Tools (model-callable) - -| Tool | Description | -|---|---| -| `slop_ledger_append` | Append entries with bucket, severity, confidence, title, description | -| `slop_ledger_query` | Query with bucket/severity/status/text filters | -| `slop_ledger_update` | Update entry status | -| `slop_ledger_export` | Export as Markdown for handoffs / GitHub issues | - -### Slash Command - -- `/slop` — print summary -- `/slop query` — list entries -- `/slop export` — Markdown export -- Alias: `/canzha` - -### Files Changed - -| File | Change | -|---|---| -| `crates/tui/src/slop_ledger.rs` | **New** — 1089 lines | -| `crates/tui/src/main.rs` | +1: mod declaration | -| `crates/tui/src/tools/registry.rs` | +16: builder method | -| `crates/tui/src/core/engine/tool_setup.rs` | +1: registration | -| `crates/tui/src/commands/mod.rs` | +10: command + dispatch | -| `crates/tui/src/commands/config.rs` | +41: handler | - -### Tests - -8 unit tests: bucket roundtrip, save/load, query by bucket/search, -update status, markdown export, empty ledger, summary counts. - -## How to Test - -```bash -cargo test -p codewhale-tui -- slop_ledger -``` - -In TUI: `/slop`, `/slop query`, `/slop export` From c47ed896dc2ccb8145ad6f35b8d06864f1e3076c Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 06:03:10 -0500 Subject: [PATCH 029/283] =?UTF-8?q?fix:=20DeepSeek-first=20v0.8.45=20?= =?UTF-8?q?=E2=80=94=20CODEWHALE=5F*=20env=20aliases,=20remove=20public=20?= =?UTF-8?q?Kimi/Moonshot=20promotion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2164 (superseded). --- CHANGELOG.md | 19 ++- README.md | 41 ++---- crates/config/src/lib.rs | 183 +++++++++++++++++++++++++- crates/tui/CHANGELOG.md | 19 ++- crates/tui/src/config.rs | 269 ++++++++++++++++++++++++++++++++++++--- docs/CONFIGURATION.md | 32 +++-- 6 files changed, 494 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d0f05ae..7b8fee06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Fixed +### Added -- **Kimi Code API-key setup.** `codewhale config set providers.moonshot.*` - now writes the Moonshot/Kimi provider table, and Kimi Code API-key - endpoints default to `kimi-for-coding` without using the Kimi CLI OAuth path. +- **`CODEWHALE_*` env aliases.** `CODEWHALE_PROVIDER`, `CODEWHALE_MODEL`, + and `CODEWHALE_BASE_URL` are public product-scoped aliases that take + precedence over the legacy `DEEPSEEK_*` forms. The `DEEPSEEK_*` names + remain accepted for back-compat. Recommended setup paths are + `codewhale --provider `, `provider = ""` in + `~/.codewhale/config.toml`, or `CODEWHALE_PROVIDER=`. + +### Changed + +- **DeepSeek-first focus.** v0.8.45.x refocuses on delivering the + highest-quality experience on DeepSeek first. The project's broader + goal remains to become a strong harness for open-source and open-weight + coding models, but additional first-class provider paths are planned + for v0.9.0 after the core DeepSeek workflow is solid. ## [0.8.45] - 2026-05-25 diff --git a/README.md b/README.md index 450ca64d..b5e27147 100644 --- a/README.md +++ b/README.md @@ -275,11 +275,12 @@ Both binaries are required. Cross-compilation and platform-specific notes: [docs ### Providers -Official DeepSeek remains the default and first-class path. v0.8.45 supports -all 12 provider IDs in this order: `deepseek`, `nvidia-nim`, `openai`, -`atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `moonshot`, -`sglang`, `vllm`, and `ollama`. Other providers are additive, with OpenRouter -starting from DeepSeek Pro/Flash before broader open-model catalogs are enabled. +CodeWhale v0.8.45 focuses on delivering the highest-quality experience on +DeepSeek first. The project's broader goal remains to become a strong harness +for open-source and open-weight coding models — additional first-class +provider paths are planned for v0.9.0. Backend provider infrastructure for +other OpenAI-compatible endpoints and self-hosted runtimes is available under +the same `--provider` flag for advanced users who need it today. ```bash # DeepSeek (default) @@ -314,24 +315,6 @@ codewhale --provider novita --model deepseek/deepseek-v4-pro codewhale auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY" codewhale --provider fireworks --model deepseek-v4-pro -# Kimi Code plan API key -codewhale auth set --provider moonshot --api-key "YOUR_KIMI_CODE_API_KEY" -codewhale config set providers.moonshot.auth_mode api_key -codewhale config set providers.moonshot.base_url https://api.kimi.com/coding/v1 -codewhale config set providers.moonshot.model kimi-for-coding -codewhale --provider moonshot - -# Kimi/Moonshot Platform API key -codewhale auth set --provider moonshot --api-key "YOUR_MOONSHOT_OR_KIMI_API_KEY" -codewhale config set providers.moonshot.auth_mode api_key -codewhale config set providers.moonshot.base_url https://api.moonshot.ai/v1 -codewhale config set providers.moonshot.model kimi-k2.6 -codewhale --provider moonshot - -# Kimi through OpenRouter's catalog -codewhale auth set --provider openrouter --api-key "YOUR_OPENROUTER_API_KEY" -codewhale --provider openrouter --model moonshotai/kimi-k2.6 - # Self-hosted SGLang SGLANG_BASE_URL="http://localhost:30000/v1" codewhale --provider sglang --model deepseek-v4-flash @@ -506,21 +489,23 @@ Key environment variables: | Variable | Purpose | |---|---| +| `CODEWHALE_PROVIDER` | Active provider. Public alias for `DEEPSEEK_PROVIDER`; wins when both are set. | +| `CODEWHALE_MODEL` | Default model for the active provider. Public alias for `DEEPSEEK_MODEL`. | +| `CODEWHALE_BASE_URL` | Base URL for the active provider. Public alias for `DEEPSEEK_BASE_URL`. | | `DEEPSEEK_API_KEY` | API key | -| `DEEPSEEK_BASE_URL` | API base URL | +| `DEEPSEEK_BASE_URL` | API base URL (legacy alias of `CODEWHALE_BASE_URL`) | | `DEEPSEEK_HTTP_HEADERS` | Optional custom model request headers, e.g. `X-Model-Provider-Id=your-model-provider` | -| `DEEPSEEK_MODEL` | Default model | +| `DEEPSEEK_MODEL` | Default model (legacy alias of `CODEWHALE_MODEL`) | | `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | Stream idle timeout in seconds, default `300`, clamped to `1..=3600` | -| `DEEPSEEK_PROVIDER` | `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `moonshot`, `sglang`, `vllm`, `ollama` | +| `DEEPSEEK_PROVIDER` | Legacy alias of `CODEWHALE_PROVIDER`. Accepts `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `sglang`, `vllm`, `ollama`. | | `DEEPSEEK_PROFILE` | Config profile name | | `DEEPSEEK_MEMORY` | Set to `on` to enable user memory | | `DEEPSEEK_ALLOW_INSECURE_HTTP=1` | Allow non-local `http://` API base URLs on trusted networks | -| `NVIDIA_API_KEY` / `NVIDIA_NIM_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `WANJIE_API_KEY` / `WANJIE_MAAS_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `MOONSHOT_API_KEY` / `KIMI_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | Provider auth | +| `NVIDIA_API_KEY` / `NVIDIA_NIM_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `WANJIE_API_KEY` / `WANJIE_MAAS_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | Provider auth | | `NVIDIA_NIM_BASE_URL` / `NIM_BASE_URL` / `NVIDIA_BASE_URL` | NVIDIA NIM endpoint override | | `OPENAI_BASE_URL` / `OPENAI_MODEL` | Generic OpenAI-compatible endpoint and model ID | | `ATLASCLOUD_BASE_URL` / `ATLASCLOUD_MODEL` | AtlasCloud endpoint and model override | | `WANJIE_ARK_BASE_URL` / `WANJIE_BASE_URL` / `WANJIE_MAAS_BASE_URL` / `WANJIE_ARK_MODEL` / `WANJIE_MODEL` / `WANJIE_MAAS_MODEL` | Wanjie Ark endpoint and model override | -| `MOONSHOT_BASE_URL` / `KIMI_BASE_URL` / `MOONSHOT_MODEL` / `KIMI_MODEL_NAME` / `KIMI_MODEL` | Moonshot/Kimi endpoint and model override. For a Kimi Code plan API key, use `KIMI_BASE_URL=https://api.kimi.com/coding/v1` and `KIMI_MODEL=kimi-for-coding`. | | `OPENROUTER_BASE_URL` | OpenRouter endpoint override | | `NOVITA_BASE_URL` | Novita endpoint override | | `FIREWORKS_BASE_URL` | Fireworks endpoint override | diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index d9d72864..039c28c7 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1787,10 +1787,15 @@ struct EnvRuntimeOverrides { impl EnvRuntimeOverrides { fn load() -> Self { Self { - provider: std::env::var("DEEPSEEK_PROVIDER") + provider: std::env::var("CODEWHALE_PROVIDER") + .or_else(|_| std::env::var("DEEPSEEK_PROVIDER")) .ok() .and_then(|v| ProviderKind::parse(&v)), - model: std::env::var("DEEPSEEK_MODEL").ok(), + model: std::env::var("CODEWHALE_MODEL") + .or_else(|_| std::env::var("DEEPSEEK_MODEL")) + .or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL")) + .ok() + .filter(|v| !v.trim().is_empty()), wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL") .or_else(|_| std::env::var("WANJIE_MODEL")) .or_else(|_| std::env::var("WANJIE_MAAS_MODEL")) @@ -1816,7 +1821,8 @@ impl EnvRuntimeOverrides { .ok() .and_then(|value| parse_http_headers(&value).ok()) .filter(|headers| !headers.is_empty()), - deepseek_base_url: std::env::var("DEEPSEEK_BASE_URL") + deepseek_base_url: std::env::var("CODEWHALE_BASE_URL") + .or_else(|_| std::env::var("DEEPSEEK_BASE_URL")) .ok() .filter(|v| !v.trim().is_empty()), nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL") @@ -1921,6 +1927,7 @@ mod tests { deepseek_base_url: Option, deepseek_http_headers: Option, deepseek_model: Option, + deepseek_default_text_model: Option, deepseek_provider: Option, deepseek_auth_mode: Option, nvidia_api_key: Option, @@ -1954,6 +1961,9 @@ mod tests { vllm_base_url: Option, ollama_api_key: Option, ollama_base_url: Option, + codewhale_provider: Option, + codewhale_model: Option, + codewhale_base_url: Option, } impl EnvGuard { @@ -1963,8 +1973,12 @@ mod tests { deepseek_base_url: env::var_os("DEEPSEEK_BASE_URL"), deepseek_http_headers: env::var_os("DEEPSEEK_HTTP_HEADERS"), deepseek_model: env::var_os("DEEPSEEK_MODEL"), + deepseek_default_text_model: env::var_os("DEEPSEEK_DEFAULT_TEXT_MODEL"), deepseek_provider: env::var_os("DEEPSEEK_PROVIDER"), deepseek_auth_mode: env::var_os("DEEPSEEK_AUTH_MODE"), + codewhale_provider: env::var_os("CODEWHALE_PROVIDER"), + codewhale_model: env::var_os("CODEWHALE_MODEL"), + codewhale_base_url: env::var_os("CODEWHALE_BASE_URL"), nvidia_api_key: env::var_os("NVIDIA_API_KEY"), nvidia_nim_api_key: env::var_os("NVIDIA_NIM_API_KEY"), nim_base_url: env::var_os("NIM_BASE_URL"), @@ -2003,8 +2017,12 @@ mod tests { env::remove_var("DEEPSEEK_BASE_URL"); env::remove_var("DEEPSEEK_HTTP_HEADERS"); env::remove_var("DEEPSEEK_MODEL"); + env::remove_var("DEEPSEEK_DEFAULT_TEXT_MODEL"); env::remove_var("DEEPSEEK_PROVIDER"); env::remove_var("DEEPSEEK_AUTH_MODE"); + env::remove_var("CODEWHALE_PROVIDER"); + env::remove_var("CODEWHALE_MODEL"); + env::remove_var("CODEWHALE_BASE_URL"); env::remove_var("NVIDIA_API_KEY"); env::remove_var("NVIDIA_NIM_API_KEY"); env::remove_var("NIM_BASE_URL"); @@ -2057,8 +2075,15 @@ mod tests { Self::restore_var("DEEPSEEK_BASE_URL", self.deepseek_base_url.take()); Self::restore_var("DEEPSEEK_HTTP_HEADERS", self.deepseek_http_headers.take()); Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take()); + Self::restore_var( + "DEEPSEEK_DEFAULT_TEXT_MODEL", + self.deepseek_default_text_model.take(), + ); Self::restore_var("DEEPSEEK_PROVIDER", self.deepseek_provider.take()); Self::restore_var("DEEPSEEK_AUTH_MODE", self.deepseek_auth_mode.take()); + Self::restore_var("CODEWHALE_PROVIDER", self.codewhale_provider.take()); + Self::restore_var("CODEWHALE_MODEL", self.codewhale_model.take()); + Self::restore_var("CODEWHALE_BASE_URL", self.codewhale_base_url.take()); Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take()); Self::restore_var("NVIDIA_NIM_API_KEY", self.nvidia_nim_api_key.take()); Self::restore_var("NIM_BASE_URL", self.nim_base_url.take()); @@ -2408,6 +2433,55 @@ mod tests { ); } + /// End-to-end smoke for the preferred Kimi Code setup path: + /// 1. Start from a fresh root config that uses DeepSeek defaults. + /// 2. Mutate it through the same key-value setters the + /// `codewhale config set providers.moonshot.*` CLI invokes. + /// 3. Switch the active provider through `CODEWHALE_PROVIDER` — + /// the public env alias — without ever touching the legacy + /// `DEEPSEEK_PROVIDER` name. + /// 4. Resolve the runtime and confirm the doctor/runtime values. + /// + /// No real API key is required; the `api_key` here is just a + /// non-empty placeholder. + #[test] + fn moonshot_kimi_code_smoke_config_set_then_resolve() -> Result<()> { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + + let mut config = ConfigToml { + provider: ProviderKind::Deepseek, + default_text_model: Some("deepseek-v4-pro".to_string()), + ..ConfigToml::default() + }; + + // Same key paths a user would run via `codewhale config set`. + config.set_value("providers.moonshot.api_key", "kimi-code-key-placeholder")?; + config.set_value("providers.moonshot.auth_mode", "api_key")?; + config.set_value("providers.moonshot.base_url", DEFAULT_KIMI_CODE_BASE_URL)?; + config.set_value("providers.moonshot.model", DEFAULT_KIMI_CODE_MODEL)?; + + // Public env alias for the active-provider switch. + // Safety: test-only env mutation guarded by env_lock(). + unsafe { env::set_var("CODEWHALE_PROVIDER", "moonshot") }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL); + assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL); + assert_eq!(resolved.auth_mode.as_deref(), Some("api_key")); + assert_eq!( + resolved.api_key.as_deref(), + Some("kimi-code-key-placeholder") + ); + assert_eq!( + resolved.api_key_source, + Some(RuntimeApiKeySource::ConfigFile) + ); + Ok(()) + } + #[test] fn moonshot_provider_config_values_round_trip() -> Result<()> { let mut config = ConfigToml::default(); @@ -2757,6 +2831,109 @@ mod tests { ); } + /// `CODEWHALE_PROVIDER` is the user-facing env alias for switching the + /// active provider. It must be honored by the runtime resolver and win + /// over a root `provider = "deepseek"` config entry. + #[test] + fn codewhale_provider_env_switches_active_provider() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only env mutation guarded by env_lock(). + unsafe { + env::set_var("CODEWHALE_PROVIDER", "moonshot"); + } + let mut config = ConfigToml { + provider: ProviderKind::Deepseek, + ..ConfigToml::default() + }; + config.providers.moonshot.api_key = Some("kimi-code-key".to_string()); + config.providers.moonshot.base_url = Some(DEFAULT_KIMI_CODE_BASE_URL.to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL); + assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL); + assert_eq!(resolved.api_key.as_deref(), Some("kimi-code-key")); + } + + /// When both `CODEWHALE_PROVIDER` and the legacy `DEEPSEEK_PROVIDER` + /// are set, the public alias wins — a user adopting `CODEWHALE_*` in a + /// fresh shell config is not tripped up by a stale legacy export still + /// living in their dotfiles. + #[test] + fn codewhale_provider_env_wins_over_deepseek_provider_env() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only env mutation guarded by env_lock(). + unsafe { + env::set_var("CODEWHALE_PROVIDER", "moonshot"); + env::set_var("DEEPSEEK_PROVIDER", "openrouter"); + } + let config = ConfigToml { + provider: ProviderKind::Deepseek, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + } + + /// `CODEWHALE_MODEL` is the user-facing env alias for picking a model + /// against the active provider. It must be honored by the runtime + /// resolver in place of `DEEPSEEK_MODEL`. + #[test] + fn codewhale_model_env_alias_overrides_default_for_active_provider() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only env mutation guarded by env_lock(). + unsafe { + env::set_var("CODEWHALE_PROVIDER", "moonshot"); + env::set_var("CODEWHALE_MODEL", "custom-kimi-test-model"); + } + let config = ConfigToml::default(); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.model, "custom-kimi-test-model"); + } + + #[test] + fn blank_codewhale_model_env_alias_does_not_override_default_for_active_provider() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only env mutation guarded by env_lock(). + unsafe { + env::set_var("CODEWHALE_PROVIDER", "moonshot"); + env::set_var("CODEWHALE_MODEL", " "); + } + let config = ConfigToml::default(); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.model, DEFAULT_MOONSHOT_MODEL); + } + + #[test] + fn deepseek_default_text_model_legacy_alias_still_overrides_active_provider_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only env mutation guarded by env_lock(). + unsafe { + env::set_var("CODEWHALE_PROVIDER", "moonshot"); + env::set_var("DEEPSEEK_DEFAULT_TEXT_MODEL", "legacy-env-model"); + } + let config = ConfigToml::default(); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.model, "legacy-env-model"); + } + #[test] fn wanjie_ark_provider_defaults_to_openai_compatible_endpoint_and_model() { let _lock = env_lock(); diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 2d0f05ae..7b8fee06 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,11 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Fixed +### Added -- **Kimi Code API-key setup.** `codewhale config set providers.moonshot.*` - now writes the Moonshot/Kimi provider table, and Kimi Code API-key - endpoints default to `kimi-for-coding` without using the Kimi CLI OAuth path. +- **`CODEWHALE_*` env aliases.** `CODEWHALE_PROVIDER`, `CODEWHALE_MODEL`, + and `CODEWHALE_BASE_URL` are public product-scoped aliases that take + precedence over the legacy `DEEPSEEK_*` forms. The `DEEPSEEK_*` names + remain accepted for back-compat. Recommended setup paths are + `codewhale --provider `, `provider = ""` in + `~/.codewhale/config.toml`, or `CODEWHALE_PROVIDER=`. + +### Changed + +- **DeepSeek-first focus.** v0.8.45.x refocuses on delivering the + highest-quality experience on DeepSeek first. The project's broader + goal remains to become a strong harness for open-source and open-weight + coding models, but additional first-class provider paths are planned + for v0.9.0 after the core DeepSeek workflow is solid. ## [0.8.45] - 2026-05-25 diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 78975ee3..a51a3c8b 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1554,6 +1554,19 @@ impl Config { } } } + let moonshot_config = (provider == ApiProvider::Moonshot) + .then(|| self.provider_config()) + .flatten(); + let moonshot_uses_kimi_code = moonshot_config.is_some_and(|config| { + provider_config_uses_kimi_oauth(config) + || config + .base_url + .as_deref() + .is_some_and(moonshot_base_url_uses_kimi_code) + }); + if moonshot_uses_kimi_code { + return DEFAULT_KIMI_CODE_MODEL.to_string(); + } if let Some(model) = self.default_text_model.as_deref() && (provider_passes_model_through(provider) || self.active_provider_preserves_custom_base_url_model()) @@ -1570,19 +1583,6 @@ impl Config { { return model_for_provider(provider, normalized); } - let moonshot_config = (provider == ApiProvider::Moonshot) - .then(|| self.provider_config()) - .flatten(); - let moonshot_uses_kimi_code = moonshot_config.is_some_and(|config| { - provider_config_uses_kimi_oauth(config) - || config - .base_url - .as_deref() - .is_some_and(moonshot_base_url_uses_kimi_code) - }); - if moonshot_uses_kimi_code { - return DEFAULT_KIMI_CODE_MODEL.to_string(); - } match provider { ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL, @@ -2271,11 +2271,29 @@ fn default_memory_path() -> Option { // === Environment Overrides === +/// Read a CodeWhale env var, preferring the `CODEWHALE_*` form over the +/// legacy `DEEPSEEK_*` form. Empty values are ignored so a blank shell export +/// does not erase configured provider settings. +fn codewhale_env_var( + codewhale_name: &str, + legacy_name: &str, +) -> Result { + std::env::var(codewhale_name) + .ok() + .filter(|value| !value.trim().is_empty()) + .or_else(|| { + std::env::var(legacy_name) + .ok() + .filter(|value| !value.trim().is_empty()) + }) + .ok_or(std::env::VarError::NotPresent) +} + fn apply_env_overrides(config: &mut Config) { - if let Ok(value) = std::env::var("DEEPSEEK_PROVIDER") { + if let Ok(value) = codewhale_env_var("CODEWHALE_PROVIDER", "DEEPSEEK_PROVIDER") { config.provider = Some(value); } - if let Ok(value) = std::env::var("DEEPSEEK_BASE_URL") { + if let Ok(value) = codewhale_env_var("CODEWHALE_BASE_URL", "DEEPSEEK_BASE_URL") { match config.api_provider() { ApiProvider::Deepseek | ApiProvider::DeepseekCN => { config.base_url = Some(value); @@ -2558,8 +2576,13 @@ fn apply_env_overrides(config: &mut Config) { .moonshot .model = Some(value); } - if let Ok(value) = - std::env::var("DEEPSEEK_MODEL").or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL")) + if let Some(value) = codewhale_env_var("CODEWHALE_MODEL", "DEEPSEEK_MODEL") + .ok() + .or_else(|| { + std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL") + .ok() + .filter(|value| !value.trim().is_empty()) + }) { // The CLI `--model` handoff always sets DEEPSEEK_MODEL, never the // provider-specific *_MODEL var. The legacy root `default_text_model` @@ -4075,6 +4098,9 @@ mod tests { deepseek_http_headers: Option, deepseek_model: Option, deepseek_default_text_model: Option, + codewhale_provider: Option, + codewhale_model: Option, + codewhale_base_url: Option, nvidia_api_key: Option, nvidia_nim_api_key: Option, nim_base_url: Option, @@ -4137,6 +4163,9 @@ mod tests { let http_headers_prev = env::var_os("DEEPSEEK_HTTP_HEADERS"); let model_prev = env::var_os("DEEPSEEK_MODEL"); let default_text_model_prev = env::var_os("DEEPSEEK_DEFAULT_TEXT_MODEL"); + let codewhale_provider_prev = env::var_os("CODEWHALE_PROVIDER"); + let codewhale_model_prev = env::var_os("CODEWHALE_MODEL"); + let codewhale_base_url_prev = env::var_os("CODEWHALE_BASE_URL"); let nvidia_api_key_prev = env::var_os("NVIDIA_API_KEY"); let nvidia_nim_api_key_prev = env::var_os("NVIDIA_NIM_API_KEY"); let nim_base_url_prev = env::var_os("NIM_BASE_URL"); @@ -4194,6 +4223,9 @@ mod tests { env::remove_var("DEEPSEEK_HTTP_HEADERS"); env::remove_var("DEEPSEEK_MODEL"); env::remove_var("DEEPSEEK_DEFAULT_TEXT_MODEL"); + env::remove_var("CODEWHALE_PROVIDER"); + env::remove_var("CODEWHALE_MODEL"); + env::remove_var("CODEWHALE_BASE_URL"); env::remove_var("NVIDIA_API_KEY"); env::remove_var("NVIDIA_NIM_API_KEY"); env::remove_var("NIM_BASE_URL"); @@ -4251,6 +4283,9 @@ mod tests { deepseek_http_headers: http_headers_prev, deepseek_model: model_prev, deepseek_default_text_model: default_text_model_prev, + codewhale_provider: codewhale_provider_prev, + codewhale_model: codewhale_model_prev, + codewhale_base_url: codewhale_base_url_prev, nvidia_api_key: nvidia_api_key_prev, nvidia_nim_api_key: nvidia_nim_api_key_prev, nim_base_url: nim_base_url_prev, @@ -4317,6 +4352,9 @@ mod tests { "DEEPSEEK_DEFAULT_TEXT_MODEL", self.deepseek_default_text_model.take(), ); + Self::restore_var("CODEWHALE_PROVIDER", self.codewhale_provider.take()); + Self::restore_var("CODEWHALE_MODEL", self.codewhale_model.take()); + Self::restore_var("CODEWHALE_BASE_URL", self.codewhale_base_url.take()); Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take()); Self::restore_var("NVIDIA_NIM_API_KEY", self.nvidia_nim_api_key.take()); Self::restore_var("NIM_BASE_URL", self.nim_base_url.take()); @@ -6484,6 +6522,203 @@ base_url = "https://api.kimi.com/coding/v1" Ok(()) } + /// Env-var-only path: `CODEWHALE_BASE_URL=https://api.kimi.com/coding/v1` + /// combined with `CODEWHALE_PROVIDER=moonshot` must trigger Kimi Code + /// model selection even when the TOML has no `base_url`. + #[test] + fn moonshot_kimi_code_env_base_url_selects_coding_model() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-kimi-code-env-url-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + let config_path = temp_root.join(".deepseek").join("config.toml"); + ensure_parent_dir(&config_path)?; + fs::write( + &config_path, + r#"[providers.moonshot] +api_key = "kimi-code-env-key" +"#, + )?; + // Safety: test-only env mutation guarded by lock_test_env(). + unsafe { + env::set_var("CODEWHALE_PROVIDER", "moonshot"); + env::set_var("CODEWHALE_BASE_URL", "https://api.kimi.com/coding/v1"); + } + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Moonshot); + assert_eq!(config.deepseek_base_url(), DEFAULT_KIMI_CODE_BASE_URL); + assert_eq!(config.default_model(), DEFAULT_KIMI_CODE_MODEL); + assert_eq!(config.deepseek_api_key()?, "kimi-code-env-key"); + assert!(has_api_key_for(&config, ApiProvider::Moonshot)); + Ok(()) + } + + /// Regression for issue #2160: a stale root `default_text_model` carried + /// over from a DeepSeek setup must not steer the Kimi Code endpoint to + /// `deepseek-v4-pro`. The user-facing trigger here is the legacy + /// `DEEPSEEK_PROVIDER` env var (still produced by the `codewhale + /// --provider moonshot` dispatcher for compat); the test also has a + /// `CODEWHALE_PROVIDER` twin below for the public env path. + #[test] + fn moonshot_kimi_code_model_overrides_root_deepseek_default() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-kimi-code-root-model-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + let config_path = temp_root.join(".deepseek").join("config.toml"); + ensure_parent_dir(&config_path)?; + fs::write( + &config_path, + r#"provider = "deepseek" +default_text_model = "deepseek-v4-pro" + +[providers.moonshot] +api_key = "kimi-code-key" +base_url = "https://api.kimi.com/coding/v1" +"#, + )?; + // Safety: test-only env mutation guarded by lock_test_env(). + unsafe { env::set_var("DEEPSEEK_PROVIDER", "moonshot") }; + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Moonshot); + assert_eq!(config.deepseek_base_url(), DEFAULT_KIMI_CODE_BASE_URL); + assert_eq!(config.default_model(), DEFAULT_KIMI_CODE_MODEL); + Ok(()) + } + + /// Same regression as above, but driven by the public `CODEWHALE_PROVIDER` + /// env var. Documents the recommended user-facing setup path: never + /// `DEEPSEEK_PROVIDER=moonshot`, always `CODEWHALE_PROVIDER=moonshot` + /// (or `codewhale --provider moonshot`, which also resolves through + /// this code path internally). + #[test] + fn moonshot_kimi_code_model_resolves_via_codewhale_provider_env() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-kimi-code-cw-env-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + let config_path = temp_root.join(".deepseek").join("config.toml"); + ensure_parent_dir(&config_path)?; + fs::write( + &config_path, + r#"provider = "deepseek" +default_text_model = "deepseek-v4-pro" + +[providers.moonshot] +api_key = "kimi-code-key" +base_url = "https://api.kimi.com/coding/v1" +"#, + )?; + // Safety: test-only env mutation guarded by lock_test_env(). + unsafe { env::set_var("CODEWHALE_PROVIDER", "moonshot") }; + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Moonshot); + assert_eq!(config.deepseek_base_url(), DEFAULT_KIMI_CODE_BASE_URL); + assert_eq!(config.default_model(), DEFAULT_KIMI_CODE_MODEL); + Ok(()) + } + + /// `CODEWHALE_PROVIDER` wins when both it and the legacy + /// `DEEPSEEK_PROVIDER` are set, so a user adding the new alias to their + /// shell isn't surprised by a stale legacy export. + #[test] + fn codewhale_provider_env_takes_precedence_over_deepseek_provider() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-cw-vs-ds-provider-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + let config_path = temp_root.join(".deepseek").join("config.toml"); + ensure_parent_dir(&config_path)?; + fs::write(&config_path, "provider = \"deepseek\"\n")?; + // Safety: test-only env mutation guarded by lock_test_env(). + unsafe { + env::set_var("CODEWHALE_PROVIDER", "moonshot"); + env::set_var("DEEPSEEK_PROVIDER", "openrouter"); + } + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Moonshot); + Ok(()) + } + + /// Moonshot Platform path: when [providers.moonshot] is empty (or + /// missing) and no Kimi Code endpoint is configured, the resolver + /// defaults to the Moonshot Platform base URL and the `kimi-k2.6` + /// model. This is the "I have a Moonshot Platform API key, not a + /// Kimi Code plan key" path. + #[test] + fn moonshot_platform_defaults_to_kimi_k26() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-moonshot-platform-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + let config_path = temp_root.join(".deepseek").join("config.toml"); + ensure_parent_dir(&config_path)?; + fs::write( + &config_path, + r#"provider = "moonshot" + +[providers.moonshot] +api_key = "moonshot-platform-key" +"#, + )?; + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Moonshot); + assert_eq!(config.deepseek_base_url(), DEFAULT_MOONSHOT_BASE_URL); + assert_eq!(config.default_model(), DEFAULT_MOONSHOT_MODEL); + assert_eq!(config.deepseek_api_key()?, "moonshot-platform-key"); + Ok(()) + } + #[test] fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> { let _lock = lock_test_env(); diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index c63e5b9b..ed15c9d6 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -63,15 +63,14 @@ provider's keyring entry. For hosted, generic OpenAI-compatible, or self-hosted providers, set `provider = "nvidia-nim"`, `"openai"`, `"atlascloud"`, `"wanjie-ark"`, `"fireworks"`, -`"moonshot"`, `"sglang"`, `"vllm"`, or `"ollama"` or pass `codewhale --provider `. +`"sglang"`, `"vllm"`, or `"ollama"` or pass `codewhale --provider `. The facade saves provider credentials to the shared user config and forwards the resolved key, base URL, provider, and model to the TUI process. Use `codewhale auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"` or `codewhale auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY"` or `codewhale auth set --provider atlascloud --api-key "YOUR_ATLASCLOUD_API_KEY"` or `codewhale auth set --provider wanjie-ark --api-key "YOUR_WANJIE_API_KEY"` or -`codewhale auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"` or -`codewhale auth set --provider moonshot --api-key "YOUR_MOONSHOT_OR_KIMI_API_KEY"` +`codewhale auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"` to save provider keys through the facade. The generic `openai` provider defaults to `https://api.openai.com/v1`, accepts `OPENAI_BASE_URL`, and defaults to `deepseek-v4-pro` for OpenAI-compatible gateways. `atlascloud` defaults to @@ -80,9 +79,7 @@ to `https://api.openai.com/v1`, accepts `OPENAI_BASE_URL`, and defaults to Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`, defaults to `deepseek-reasoner`, and passes model IDs through unchanged because Wanjie model access is -account-scoped. `moonshot` targets Moonshot/Kimi, defaults to `kimi-k2.6`, -and can use `KIMI_API_KEY` or `auth_mode = "kimi_oauth"` with local Kimi CLI -credentials. SGLang, vLLM, and Ollama are +account-scoped. SGLang, vLLM, and Ollama are self-hosted and can run without an API key by default. Ollama defaults to `http://localhost:11434/v1` and sends model tags such as `codewhale-coder:1.3b` or `qwen2.5-coder:7b` unchanged. Self-hosted providers and loopback custom @@ -200,13 +197,22 @@ If a profile is selected but missing, codewhale exits with an error listing avai ## Environment Variables Most runtime environment variables override config values. API-key variables are -fallbacks after saved config and keyring credentials: +fallbacks after saved config and keyring credentials. + +The three user-facing slots — provider, model, base URL — expose `CODEWHALE_*` +aliases. When both forms are set the `CODEWHALE_*` value wins; the +`DEEPSEEK_*` form is kept for older shells: + +- `CODEWHALE_PROVIDER` (preferred) / `DEEPSEEK_PROVIDER` (legacy alias) — + `deepseek|nvidia-nim|openai|atlascloud|wanjie-ark|openrouter|novita|fireworks|sglang|vllm|ollama` +- `CODEWHALE_MODEL` (preferred) / `DEEPSEEK_MODEL` (legacy alias) — default model for the active provider +- `CODEWHALE_BASE_URL` (preferred) / `DEEPSEEK_BASE_URL` (legacy alias) — base URL for the active provider + +Remaining variables: - `DEEPSEEK_API_KEY` -- `DEEPSEEK_BASE_URL` - `DEEPSEEK_HTTP_HEADERS` (custom model request headers, comma-separated `name=value` pairs) -- `DEEPSEEK_PROVIDER` (`codewhale|nvidia-nim|openai|atlascloud|wanjie-ark|openrouter|novita|fireworks|moonshot|sglang|vllm|ollama`) -- `DEEPSEEK_MODEL` or `DEEPSEEK_DEFAULT_TEXT_MODEL` +- `DEEPSEEK_DEFAULT_TEXT_MODEL` (extra legacy alias of `DEEPSEEK_MODEL`) - `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` (stream idle timeout in seconds; default `300`, clamped to `1..=3600`) - `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` (connection setup + response-header wait in seconds; default `45`, clamped to `5..=300`; distinct from the per-chunk idle timeout) - `NVIDIA_API_KEY` or `NVIDIA_NIM_API_KEY` (preferred when provider is `nvidia-nim`; falls back to `DEEPSEEK_API_KEY`) @@ -429,10 +435,10 @@ If you are upgrading from older releases: ### Core keys (used by the TUI/engine) -- `provider` (string, optional): `codewhale` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `moonshot`, `sglang`, `vllm`, or `ollama`. Legacy `deepseek-cn` configs are still accepted as an alias for `codewhale`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `moonshot` targets `https://api.moonshot.ai/v1` by default, with Kimi CLI OAuth mode using `https://api.kimi.com/coding/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`. +- `provider` (string, optional): `codewhale` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `sglang`, `vllm`, or `ollama`. Legacy `deepseek-cn` configs are still accepted as an alias for `codewhale`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`. - `api_key` (string, required for hosted providers): must be non-empty for DeepSeek/hosted providers (or set the provider API key env var). Self-hosted SGLang, vLLM, and Ollama can omit it. -- `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs, `https://api.openai.com/v1` for `provider = "openai"`, `https://api.atlascloud.ai/v1` for `provider = "atlascloud"`, `https://maas-openapi.wanjiedata.com/api/v1` for `provider = "wanjie-ark"`, `https://api.moonshot.ai/v1` for `provider = "moonshot"` API-key mode, or the provider-specific endpoint for hosted/self-hosted providers. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. -- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `kimi-k2.6` for Moonshot/Kimi API-key mode, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `codewhale-coder:1.3b` for Ollama. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. Generic `openai`, `atlascloud`, `wanjie-ark`, and Ollama model IDs are passed through unchanged. OpenRouter provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `DEEPSEEK_MODEL` overrides this for a single process. +- `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs, `https://api.openai.com/v1` for `provider = "openai"`, `https://api.atlascloud.ai/v1` for `provider = "atlascloud"`, `https://maas-openapi.wanjiedata.com/api/v1` for `provider = "wanjie-ark"`, or the provider-specific endpoint for hosted/self-hosted providers. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. +- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `codewhale-coder:1.3b` for Ollama. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. Generic `openai`, `atlascloud`, `wanjie-ark`, and Ollama model IDs are passed through unchanged. OpenRouter provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `DEEPSEEK_MODEL` overrides this for a single process. - `reasoning_effort` (string, optional): `off`, `low`, `medium`, `high`, or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`. - `allow_shell` (bool, optional): defaults to `true` (sandboxed). - `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases. From 325aec396dd7ee442169617108de09c505b86224 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 06:40:12 -0500 Subject: [PATCH 030/283] =?UTF-8?q?docs:=20v0.8.45=20harness=20framing=20?= =?UTF-8?q?=E2=80=94=20READMEs,=20website,=20metadata,=20contributors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: new tagline, What Is It/Key Features → harness framing - README.zh-CN/ja-JP: matching Chinese/Japanese translations - web/app/page.tsx: updated hero, description, three-column grid, flywheel - web/app/layout.tsx: metadata title + description for harness framing - website/index.html + zh/: hero, feature grid, install command - README: updated contributor credits for v0.8.45 (idling11, gaord, h3c-hexin, cyq1017, reidliu41, wdw8276, zlh124) --- README.ja-JP.md | 47 +++++----- README.md | 141 ++++++++++++------------------ README.zh-CN.md | 50 +++++------ web/app/[locale]/docs/page.tsx | 56 +++++------- web/app/[locale]/faq/page.tsx | 22 +++-- web/app/[locale]/page.tsx | 57 ++++++------ web/app/[locale]/roadmap/page.tsx | 26 +++--- web/app/layout.tsx | 6 +- website/index.html | 38 ++++---- website/zh/index.html | 38 ++++---- 10 files changed, 220 insertions(+), 261 deletions(-) diff --git a/README.ja-JP.md b/README.ja-JP.md index dc21668d..9916ec32 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -1,6 +1,6 @@ # 🐳 CodeWhale -> **DeepSeek ファーストで、オープンソースおよびオープンウェイトのコーディングモデルに向けたターミナルネイティブのコーディングエージェントです。DeepSeek V4 の 100 万トークンのコンテキストウィンドウとプレフィックスキャッシュ機能を中心に構築されています。単一のバイナリとして配布され、Node.js や Python のランタイムは不要です。MCP クライアント、サンドボックス、永続的なタスクキューも標準で同梱されています。** +> **DeepSeek V4 のための最もエージェント的なハーネス。ルール、ツール、証拠、フィードバックループ——モデルがタスクを完了するまで働き続け、さらに改善し続けるための仕組みです。** [![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/codewhale)](https://www.npmjs.com/package/codewhale) @@ -63,34 +63,35 @@ cargo install codewhale-tui --locked --force ## codewhale とは? -codewhale は、ターミナル内で完結するコーディングエージェントです。DeepSeek のフロンティアモデルがあなたのワークスペースに直接アクセスできるようにし、ファイルの読み取り・編集、シェルコマンドの実行、Web 検索、Git 管理、サブエージェントの統制などを、すべて高速でキーボード駆動の TUI を通じて行えます。 +モデルは質問に答えます。エージェントはタスクを完了します。その差がハーネス——モデルを取り巻くルール、ツール、証拠、フィードバックループという動作環境です。 -**DeepSeek V4 向けに構築** (`deepseek-v4-pro` / `deepseek-v4-flash`)。100 万トークンのコンテキストウィンドウとネイティブの thinking-mode(思考連鎖)ストリーミングをサポートします。 +CodeWhale はそのハーネスであり、DeepSeek V4 Pro と Flash のために構築されました。メンテナがモデルがタスクの途中で方向を見失ったり、ユーザーの現在の要求より古い指示に従ったり、コマンドが失敗すると諦めたりすることにうんざりしたことから、個人ツールとして始まりました。そこから生まれたのが、モデルの方向性を保つシステムです:憲法的なプロンプト階層、構造化された信頼境界、並列サブエージェント、プレフィックスキャッシュ対応のコンテキスト管理、そしてモデルが自己修正するための十分なシグナルを提供する検証の鼓動。 -### 主な機能 +DeepSeek V4 はこのハーネスの一部を書くのを手伝いました。これは重要です——CodeWhale がすでに V4 を使う最も効果的な方法であり、V4 が改善するにつれてハーネスも改善することを意味します。各ターンがより良いプロンプト、より良いルール、より良いハンドオフを残します。次のターンはより強い位置から始まります。 -- **モデル自動ルーティング** — `--model auto` / `/model auto` がターンごとにモデルと推論強度を選択 -- **Fin の高速経路** — thinking off の低コストな `deepseek-v4-flash` がルーティング、RLM 子呼び出し、要約、調整作業を担当 -- **ネイティブ RLM** (`rlm_open`/`rlm_eval`) — 永続 REPL セッションでバッチ解析を行い、`peek`、`search`、`chunk`、`sub_query_batch` などの補助関数を利用 -- **Thinking-mode ストリーミング** — モデルがタスクに取り組む様子をリアルタイムで観察し、思考連鎖の展開を追える -- **完全なツールスイート** — ファイル操作、シェル実行、Git、Web 検索/ブラウズ、apply-patch、サブエージェント、MCP サーバー -- **100 万トークンコンテキスト** — コンテキスト追跡、手動または設定ベースのコンパクション、プレフィックスキャッシュのテレメトリ -- **3 つのモード** — Plan(読み取り専用の探索)、Agent(承認ありのインタラクティブ)、YOLO(自動承認) -- **推論努力ティア** — `Shift + Tab` で `off → high → max` を切り替え -- **セッション保存/再開** — 長時間実行のセッションをチェックポイント化して再開可能 -- **ワークスペースのロールバック** — リポジトリの `.git` には触れずに、サイド Git によるターン前後のスナップショットを `/restore` と `revert_turn` で扱える -- **永続的タスクキュー** — 再起動を超えて生き残るバックグラウンドタスク。スケジュール自動化や長時間レビューなどに -- **HTTP/SSE ランタイム API** — `codewhale serve --http` でヘッドレスエージェントワークフローを実現 -- **MCP プロトコル** — Model Context Protocol サーバーに接続して拡張ツールを利用可能。詳細は [docs/MCP.md](docs/MCP.md) を参照 -- **LSP 診断** — rust-analyzer、pyright、typescript-language-server、gopls、clangd により、編集ごとにエラー/警告をインライン表示 -- **ユーザーメモリ** — クロスセッションの嗜好をシステムプロンプトに注入できる、オプションの永続メモファイル -- **ローカライズ済み UI** — `en`、`ja`、`zh-Hans`、`pt-BR` を自動検出 -- **ライブコスト追跡** — ターンごと/セッションごとのトークン使用量とコスト見積もり、キャッシュヒット/ミスの内訳 -- **スキルシステム** — GitHub から取得できる命令パック。初回起動時に `skill-creator`、`mcp-builder`、`documents`、`presentations`、`spreadsheets`、`pdf`、`feishu` などのスターターセットを同梱 +### ハーネスの仕組み + +**憲法的階層。** システムプロンプトは法の抵触エンジンです。ユーザーの意図は古いメモリより上。ライブの証拠は仮定より上。検証は自信より上。各ターンは明確な権限チェーンを継承するので、モデルはどの指示に従うべきか推測する必要がありません。 + +**構造化された信頼。** ハードな境界を持つ3つのモード——Plan(読み取り専用)、Agent(承認ゲート付き)、YOLO(信頼済みワークスペースで自動承認)。OS レベルのサンドボックスが境界を支えます:macOS の Seatbelt、Linux の Landlock、Windows の Job Objects。危険なコマンドはモードに関係なく分類・ゲート制御されます。 + +**フィードバックの鼓動。** 失敗したコマンド、失敗したテスト、LSP 診断——これらは行き止まりではありません。モデルがチューニングできる信号です。非ゼロの終了コードは情報です。型エラーは修正ベクトルです。ハーネスは失敗を読み取り可能にし、ユーザーが常に舵を取り直さなくてもモデルが回復できるようにします。 + +**継続性。** メモリはセッションをまたいで持続します。ハンドオフはコンテキスト圧縮後も生存します。セッションは保存・再開・兄弟パスへのフォークが可能です。次の知性——人間であれ機械であれ——が、すでに学ばれたことを再発見する必要なく、前回の中断点から引き継ぎます。 + +**分散作業。** サブエージェントが並行実行、一度に最大 20。`agent_open` は即座に戻り、親は作業を続けます。結果は構造化された完了イベントとして到着し、境界付きハンドルで——完全なトランスクリプトで親コンテキストを埋める必要はありません。[docs/SUBAGENTS.md](docs/SUBAGENTS.md) を参照。 + +**適正な知性。** Fin——thinking off の安価な Flash——がモデル自動ルーティング、RLM 子呼び出し、要約、調整を処理します。Pro はアーキテクチャ、デバッグ、セキュリティレビューに使用。`--model auto` がターンごとにモデルと思考レベルを選択します。各問題に適切な量の知性を。 + +**長期注意の経済学。** 100 万トークンのコンテキストウィンドウとプレフィックスキャッシュテレメトリ。キャッシュヒットはミスより約 100 倍安価。`/statusline` チップがプレフィックス安定性をリアルタイムで表示し、変更がキャッシュ予算を破壊しようとしているのが見えます。 + +**回復付きの自由。** 毎ターンの side-git スナップショット(リポジトリの `.git` には触れません)。`/restore` と `revert_turn` でワークスペースを即座にロールバック。危険な操作は OS レベルでサンドボックス化。モデルに試させることができます。 + +その他の機能面:**RLM セッション**(`peek`、`search`、`chunk`、`sub_query_batch` ヘルパー付きのバッチ分析用永続 Python REPL)、**LSP 診断**(編集ごとの rust-analyzer、pyright、tsserver、gopls、clangd からのインラインエラー)、**MCP プロトコル**、**HTTP/SSE ランタイム API**、再起動を超えて存続する**永続タスクキュー**、Zed や他のエディタ向け **ACP アダプター**、**SWE-bench エクスポート**、**インストール可能なスキル**、**テーマピッカー**、**デスクトップ通知**、キャッシュヒット/ミス内訳付きの**ライブコスト追跡**。 --- -## 仕組み +## ハーネス `codewhale`(ディスパッチャー CLI)→ `codewhale-tui`(コンパニオンバイナリ)→ ratatui インターフェース ↔ 非同期エンジン ↔ OpenAI 互換のストリーミングクライアント。ツール呼び出しは型付きレジストリ(シェル、ファイル操作、Git、Web、サブエージェント、MCP、RLM)を経由してルーティングされ、結果はトランスクリプトへとストリーム返送されます。エンジンはセッション状態、ターン管理、永続タスクキューを管理し、LSP サブシステムは編集後の診断を次の推論ステップ前にモデルのコンテキストへ供給します。 diff --git a/README.md b/README.md index b5e27147..c3fd6722 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CodeWhale -> DeepSeek-first agentic terminal for open source and open-weight coding models. It runs from the `codewhale` command, streams reasoning blocks, edits local workspaces with approval gates, and can auto-route each turn to the right DeepSeek model and thinking level. +> The most agentic harness for DeepSeek V4. Rules, tools, evidence, and feedback loops that help the model keep working until the task is done — and keep getting better at it. [![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/codewhale)](https://www.npmjs.com/package/codewhale) @@ -75,38 +75,35 @@ cargo install codewhale-tui --locked --force ## What Is It? -CodeWhale is a DeepSeek-first coding agent for open source and open-weight models that runs in your terminal. It can read and edit files, run shell commands, search the web, manage git, and coordinate sub-agents from a keyboard-driven TUI. +A model answers a question. An agent finishes a task. The difference is the harness — the operating environment that surrounds the model with rules, tools, evidence, and feedback loops. -It is built around DeepSeek V4 (`deepseek-v4-pro` / `deepseek-v4-flash`), including 1M-token context windows, streaming reasoning blocks, and prefix-cache-aware cost reporting. +CodeWhale is that harness, built around DeepSeek V4 Pro and Flash. It started as a personal tool because the maintainer got tired of models losing track mid-task, obeying stale instructions over the user's current request, or giving up when a command failed. What emerged was a system that keeps the model oriented: a constitutional prompt hierarchy, structured trust boundaries, parallel sub-agents, prefix-cache-aware context management, and verification beats that give the model enough signal to self-correct. -### Key Features +DeepSeek V4 helped write parts of this harness. That matters because it means CodeWhale is already the most effective way to use V4 — and as V4 improves, the harness improves with it. Each turn leaves behind better prompts, better rules, and better handoffs. The next turn starts from a stronger position. -- **Model auto-routing** — `--model auto` / `/model auto` chooses both the model and thinking level for each turn -- **Thinking-mode streaming** — see DeepSeek reasoning blocks as the model works -- **Full tool suite** — file ops, shell execution, git, web search/browse, apply-patch, sub-agents, MCP servers -- **1M-token context** — context tracking, manual or configured compaction, and prefix-cache telemetry -- **Prefix-cache stability tracking** — an optional `/statusline` footer chip surfaces how stable the cached prefix has been across recent turns so cost-busting edits are visible before they land -- **Three modes** — Plan (read-only explore), Agent (interactive with approval), YOLO (auto-approved) -- **Reasoning-effort tiers** — cycle through `off → high → max` with `Shift + Tab` -- **Session save/resume/fork** — checkpoint long-running sessions and fork saved conversations into sibling paths with parent lineage shown in the picker -- **Workspace rollback** — side-git pre/post-turn snapshots with `/restore` and `revert_turn`, without touching your repo's `.git` -- **Approval + platform sandbox controls** — Seatbelt on macOS and Landlock on Linux where available; Windows uses the same approval flow and terminal/runtime protections while OS-level filesystem isolation remains a tracked helper contract -- **Durable task queue** — background tasks can survive restarts -- **HTTP/SSE runtime API** — `codewhale serve --http` for headless agent workflows -- **MCP protocol** — connect to Model Context Protocol servers for extended tooling; please see [docs/MCP.md](docs/MCP.md) -- **Fin-powered seams** — cheap `deepseek-v4-flash` with thinking off handles routing, RLM child calls, summaries, and other fast coordination work -- **Native RLM** (`rlm_session_objects`/`rlm_open`/`rlm_eval`) — persistent REPL sessions for batched analysis with bounded helpers like `peek`, `search`, `chunk`, and `sub_query_batch`; active prompt/history objects are opened by symbolic refs instead of pasted into the parent transcript -- **LSP diagnostics** — inline error/warning surfacing after every edit via rust-analyzer, pyright, typescript-language-server, gopls, clangd -- **User memory** — optional persistent note file injected into the system prompt for cross-session preferences -- **Localized UI** — `en`, `ja`, `zh-Hans`, `pt-BR` with auto-detection -- **Live cost tracking** — per-turn and session-level token usage and cost estimates; cache hit/miss breakdown; CNY display when the session locale is `zh-Hans` -- **Skills system** — composable, installable instruction packs from GitHub; ships with a bundled starter set (`skill-creator`, `mcp-builder`, `plugin-creator`, `v4-best-practices`, `documents`, `presentations`, `spreadsheets`, `pdf`, `feishu`, `skill-installer`, `delegate`) so `/skills` is useful from first launch -- **Terminal-native notifications** — OSC 9 (iTerm2/WezTerm/Ghostty), OSC 99 (Kitty), OSC 777 (Ghostty), plus desktop notification fallback -- **Built-in theme picker** — Catppuccin, Tokyo Night, Dracula, Gruvbox alongside the original light/dark palettes; switch live with `/theme` +### How the harness works + +**Constitutional hierarchy.** The system prompt is a conflict-of-laws engine. User intent outranks stale memory. Live evidence outranks assumptions. Verification outranks confidence. Every turn inherits a clear chain of authority, so the model never has to guess which instruction to follow. + +**Structured trust.** Three modes with hard boundaries — Plan (read-only), Agent (approval-gated), YOLO (auto-approved in trusted workspaces). OS-level sandboxing backs the boundaries: Seatbelt on macOS, Landlock on Linux, Job Objects on Windows. Dangerous commands are classified and gated regardless of mode. + +**Feedback beats.** Failed commands, failing tests, LSP diagnostics — these are not dead ends. They are signals the model can tune against. A non-zero exit code is information. A type error is a correction vector. The harness makes failure legible so the model can recover without the user constantly re-steering. + +**Continuity.** Memory persists across sessions. Handoffs survive context compaction. Sessions can be saved, resumed, and forked into sibling paths. The next intelligence — human or machine — picks up where the last one left off without having to re-discover what was already learned. + +**Distributed work.** Sub-agents run concurrently, up to 20 at a time. `agent_open` returns immediately so the parent keeps working. Results arrive as structured completion events with bounded handles — no need to flood the parent context with full transcripts. See [docs/SUBAGENTS.md](docs/SUBAGENTS.md). + +**Right-size intelligence.** Fin — cheap Flash with thinking off — handles model auto-routing, RLM child calls, summaries, and coordination. Pro engages for architecture, debugging, and security review. `--model auto` selects both the model and thinking level per turn. The right amount of intelligence for each problem. + +**Long-horizon attention economics.** 1M-token context window with prefix-cache telemetry. Cache hits cost roughly 100× less than misses. A `/statusline` chip shows prefix stability in real time so you can see when a change is about to bust the cache budget. + +**Freedom with recovery.** Every turn records a side-git snapshot that doesn't touch your repo's `.git`. `/restore` and `revert_turn` roll back the workspace instantly. Dangerous operations are sandboxed at the OS level. You can let the model try things. + +The rest of the surface: **RLM sessions** (persistent Python REPL for batched analysis with `peek`, `search`, `chunk`, and `sub_query_batch` helpers), **LSP diagnostics** (inline errors from rust-analyzer, pyright, tsserver, gopls, clangd after every edit), **MCP protocol**, **HTTP/SSE runtime API**, **persistent task queue** that survives restarts, **ACP adapter** for Zed and other editors, **SWE-bench export**, **installable skills**, **theme picker**, **desktop notifications**, and **live cost tracking** with cache hit/miss breakdowns. --- -## How It's Wired +## The Harness `codewhale` (dispatcher CLI) → `codewhale-tui` (companion binary) → ratatui interface ↔ async engine ↔ OpenAI-compatible streaming client. Tool calls route through a typed registry (shell, file ops, git, web, sub-agents, MCP, RLM) and results stream back into the transcript. The engine manages session state, turn tracking, the durable task queue, and an LSP subsystem that feeds post-edit diagnostics into the model's context before the next reasoning step. @@ -210,37 +207,12 @@ codewhale --version Prebuilt binaries can also be downloaded from [GitHub Releases](https://github.com/Hmbown/CodeWhale/releases). Use `DEEPSEEK_TUI_RELEASE_BASE_URL` for mirrored release assets. -### Windows +### Windows (Scoop) -Windows x64 is a first-class release target. Use npm or direct GitHub release -downloads when you need the newest v0.8.45 binary; Cargo also works when Rust -1.88+ and the MSVC toolchain are installed. - -```powershell -npm install -g codewhale -codewhale --version - -cargo install codewhale-cli --locked --force -cargo install codewhale-tui --locked --force -``` - -Current Windows terminal behavior: - -- interactive sessions always use the TUI-owned alternate screen; the old - `--no-alt-screen` flag is accepted for script compatibility but no longer - disables the interactive alternate screen -- runtime logs stay out of the alternate-screen buffer when `RUST_LOG` or - `DEEPSEEK_LOG_LEVEL` is enabled -- mouse capture defaults on in Windows Terminal, ConEmu, and Cmder, but stays - off in legacy console hosts and JetBrains terminals; use `--mouse-capture` or - `--no-mouse-capture` to override -- mouse-wheel-as-arrow terminals keep composer history usable by routing empty - composer Up/Down to transcript scrolling where appropriate - -[Scoop](https://scoop.sh) is also supported. The `deepseek-tui` package is -listed in Scoop's main bucket, but that manifest updates independently and can -lag the GitHub/npm/Cargo release. Run `scoop update` first, then verify the -installed version with `codewhale --version`: +[Scoop](https://scoop.sh) is a Windows package manager. The `codewhale` package is listed +in Scoop's main bucket, but that manifest updates independently and can lag the +GitHub/npm/Cargo release. Run `scoop update` first, then verify the installed +version with `codewhale --version`: ```bash scoop update @@ -273,28 +245,17 @@ Both binaries are required. Cross-compilation and platform-specific notes: [docs -### Providers +### Other API Providers -CodeWhale v0.8.45 focuses on delivering the highest-quality experience on -DeepSeek first. The project's broader goal remains to become a strong harness -for open-source and open-weight coding models — additional first-class -provider paths are planned for v0.9.0. Backend provider infrastructure for -other OpenAI-compatible endpoints and self-hosted runtimes is available under -the same `--provider` flag for advanced users who need it today. +Official DeepSeek remains the default and first-class path. Other providers are +additive, with OpenRouter starting from DeepSeek Pro/Flash before broader +open-model catalogs are enabled. ```bash -# DeepSeek (default) -codewhale auth set --provider deepseek --api-key "YOUR_DEEPSEEK_API_KEY" -codewhale --provider deepseek --model deepseek-v4-pro - # NVIDIA NIM codewhale auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY" codewhale --provider nvidia-nim -# Generic OpenAI-compatible endpoint -codewhale auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY" -OPENAI_BASE_URL="https://openai-compatible.example/v4" codewhale --provider openai --model deepseek-v4-pro - # AtlasCloud codewhale auth set --provider atlascloud --api-key "YOUR_ATLASCLOUD_API_KEY" codewhale --provider atlascloud @@ -315,6 +276,14 @@ codewhale --provider novita --model deepseek/deepseek-v4-pro codewhale auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY" codewhale --provider fireworks --model deepseek-v4-pro +# Moonshot/Kimi +codewhale auth set --provider moonshot --api-key "YOUR_MOONSHOT_OR_KIMI_API_KEY" +codewhale --provider moonshot --model kimi-k2.6 + +# Generic OpenAI-compatible endpoint +codewhale auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY" +OPENAI_BASE_URL="https://openai-compatible.example/v4" codewhale --provider openai --model deepseek-v4-pro + # Self-hosted SGLang SGLANG_BASE_URL="http://localhost:30000/v1" codewhale --provider sglang --model deepseek-v4-flash @@ -489,23 +458,20 @@ Key environment variables: | Variable | Purpose | |---|---| -| `CODEWHALE_PROVIDER` | Active provider. Public alias for `DEEPSEEK_PROVIDER`; wins when both are set. | -| `CODEWHALE_MODEL` | Default model for the active provider. Public alias for `DEEPSEEK_MODEL`. | -| `CODEWHALE_BASE_URL` | Base URL for the active provider. Public alias for `DEEPSEEK_BASE_URL`. | | `DEEPSEEK_API_KEY` | API key | -| `DEEPSEEK_BASE_URL` | API base URL (legacy alias of `CODEWHALE_BASE_URL`) | +| `DEEPSEEK_BASE_URL` | API base URL | | `DEEPSEEK_HTTP_HEADERS` | Optional custom model request headers, e.g. `X-Model-Provider-Id=your-model-provider` | -| `DEEPSEEK_MODEL` | Default model (legacy alias of `CODEWHALE_MODEL`) | +| `DEEPSEEK_MODEL` | Default model | | `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | Stream idle timeout in seconds, default `300`, clamped to `1..=3600` | -| `DEEPSEEK_PROVIDER` | Legacy alias of `CODEWHALE_PROVIDER`. Accepts `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `sglang`, `vllm`, `ollama`. | +| `DEEPSEEK_PROVIDER` | `codewhale` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `moonshot`, `sglang`, `vllm`, `ollama` | | `DEEPSEEK_PROFILE` | Config profile name | | `DEEPSEEK_MEMORY` | Set to `on` to enable user memory | | `DEEPSEEK_ALLOW_INSECURE_HTTP=1` | Allow non-local `http://` API base URLs on trusted networks | -| `NVIDIA_API_KEY` / `NVIDIA_NIM_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `WANJIE_API_KEY` / `WANJIE_MAAS_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | Provider auth | -| `NVIDIA_NIM_BASE_URL` / `NIM_BASE_URL` / `NVIDIA_BASE_URL` | NVIDIA NIM endpoint override | +| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `MOONSHOT_API_KEY` / `KIMI_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | Provider auth | | `OPENAI_BASE_URL` / `OPENAI_MODEL` | Generic OpenAI-compatible endpoint and model ID | | `ATLASCLOUD_BASE_URL` / `ATLASCLOUD_MODEL` | AtlasCloud endpoint and model override | -| `WANJIE_ARK_BASE_URL` / `WANJIE_BASE_URL` / `WANJIE_MAAS_BASE_URL` / `WANJIE_ARK_MODEL` / `WANJIE_MODEL` / `WANJIE_MAAS_MODEL` | Wanjie Ark endpoint and model override | +| `WANJIE_ARK_BASE_URL` / `WANJIE_ARK_MODEL` | Wanjie Ark endpoint and model override | +| `MOONSHOT_BASE_URL` / `KIMI_BASE_URL` / `MOONSHOT_MODEL` / `KIMI_MODEL` | Moonshot/Kimi endpoint and model override | | `OPENROUTER_BASE_URL` | OpenRouter endpoint override | | `NOVITA_BASE_URL` | Novita endpoint override | | `FIREWORKS_BASE_URL` | Fireworks endpoint override | @@ -635,8 +601,8 @@ This project ships with help from a growing community of contributors: - **[zichen0116](https://github.com/zichen0116)** — CODE_OF_CONDUCT.md (#686) - **[dfwqdyl-ui](https://github.com/dfwqdyl-ui)** — model ID case-sensitivity compatibility report (#729) - **[Oliver-ZPLiu](https://github.com/Oliver-ZPLiu)** — stale `working...` state bug report, Windows clipboard fallback, MCP Streamable HTTP session fixes, and Homebrew tap automation (#738, #850, #1643, #1631) -- **[reidliu41](https://github.com/reidliu41)** — resume hint, workspace trust persistence, Ollama provider support, thinking-block stream finalization, CI cache hardening, streaming wrap, DeepSeek model completions, help picker selection polish, and transcript user-message highlighting (#863, #870, #921, #1078, #1603, #1628, #1601, #1964, #1995) -- **[cyq1017](https://github.com/cyq1017)** — Unicode `git_status` paths, local/configured skill discovery, and mode-switch toast dedupe (#1953, #1956, #1957) +- **[reidliu41](https://github.com/reidliu41)** — resume hint, workspace trust persistence, Ollama provider support, thinking-block stream finalization, CI cache hardening, streaming wrap, DeepSeek model completions, help picker selection polish, transcript user-message highlighting, approval one-step confirmation flow, and model-picker Esc-selection fix (#863, #870, #921, #1078, #1603, #1628, #1601, #1964, #1995, #2143, #2056) +- **[cyq1017](https://github.com/cyq1017)** — Unicode `git_status` paths, local/configured skill discovery, mode-switch toast dedupe, sub-agent completion handoff compatibility, and goal-prompt actionability (#1953, #1956, #1957, #2057, #2120, #2097) - **[xieshutao](https://github.com/xieshutao)** — plain Markdown skill fallback (#869) - **[GK012](https://github.com/GK012)** — npm wrapper `--version` fallback (#885) - **[y0sif](https://github.com/y0sif)** — parent turn-loop wakeup after direct child sub-agent completion (#901) @@ -647,7 +613,7 @@ This project ships with help from a growing community of contributors: - **[chnjames](https://github.com/chnjames)** — cached @mention completions, config recovery polish, and Windows UTF-8 shell output (#849, #927, #982, #1018) - **[angziii](https://github.com/angziii)** — config safety, async cleanup, Docker hardening, and command-safety fixes (#822, #824, #827, #831, #833, #835, #837) - **[elowen53](https://github.com/elowen53)** — UTF-8 decoding and deterministic test coverage (#825, #840) -- **[wdw8276](https://github.com/wdw8276)** — `/rename` command for custom session titles (#836) +- **[wdw8276](https://github.com/wdw8276)** — `/rename` command for custom session titles and composer session-title display fix (#836, #2108) - **[banqii](https://github.com/banqii)** — `.cursor/skills` discovery path support (#817) - **[junskyeed](https://github.com/junskyeed)** — dynamic `max_tokens` calculation for API requests (#826) - **Hafeez Pizofreude** — SSRF protection in `fetch_url` and Star History chart @@ -668,11 +634,11 @@ This project ships with help from a growing community of contributors: - **[mdrkrg](https://github.com/mdrkrg)** — first-run onboarding crash fix when the API key is missing (#1598) - **[Aitensa](https://github.com/Aitensa)** — CJK wrapping propagation for diff and pager output (#1622) - **[qiyan233](https://github.com/qiyan233)** — legacy DeepSeek CN provider alias compatibility (#1645) -- **[zlh124](https://github.com/zlh124)** — WSL2/headless startup report, clipboard-init fix, and YAML block-scalar frontmatter parsing (#1772, #1773, #1908) +- **[zlh124](https://github.com/zlh124)** — WSL2/headless startup report, clipboard-init fix, and YAML block-scalar frontmatter parsing (#1772, #1773, #1908, #1907) - **[aboimpinto](https://github.com/aboimpinto)** — Windows alt-screen logging, Home/End composer, and runtime log follow-ups (#1774, #1776, #1748, #1749, #1782, #1783) - **[LeoLin990405](https://github.com/LeoLin990405)** — provider model passthrough, reasoning replay, thinking-only turn, and Windows quoting fixes (#1740, #1743, #1742, #1744) - **[nightt5879](https://github.com/nightt5879)** — Ctrl+C prompt restore fix (#1764) -- **[h3c-hexin](https://github.com/h3c-hexin)** — streaming batch tool-call preservation and CLI reasoning-effort passthrough (#1686, #1511) +- **[h3c-hexin](https://github.com/h3c-hexin)** — streaming batch tool-call preservation, CLI reasoning-effort passthrough, sub-agent completion handoff compatibility, and self-hosted context budgeting (#1686, #1511, #2057, #2120, #2060) - **[hxy91819](https://github.com/hxy91819)** — prefix-cache preservation during tool-result pruning (#1514) - **[JiarenWang](https://github.com/JiarenWang)** — Plan-mode read-only enforcement, approval-takeover clamping, Ctrl+H delete fix, and undo context sync (#1123, #962, #958, #1150) - **[Liu-Vince](https://github.com/Liu-Vince)** — MCP pagination, markdown indentation preservation, zh-Hans i18n polish, and env-var documentation (#1256, #1179, #1274, #1178) @@ -738,7 +704,8 @@ This project ships with help from a growing community of contributors: - **[xulongzhe](https://github.com/xulongzhe)** — issue-template and vision-boundary follow-ups (#1530, #1544) - **[YaYII](https://github.com/YaYII)** — trusted media path work (#1462) - **[47Cid](https://github.com/47Cid)** and **[Jafar Akhondali](https://github.com/JafarAkhondali)** — responsible security disclosures and hardening reports -- **[gaord](https://github.com/gaord)** — approval-remember live-turn sync fix (#2041) +- **[gaord](https://github.com/gaord)** — approval-remember live-turn sync fix and user-message transcript highlighting (#2041, #2047) +- **[idling11](https://github.com/idling11)** — readable `/restore` snapshot labels and sidebar hover tooltips (#2111, #2110) --- diff --git a/README.zh-CN.md b/README.zh-CN.md index 97fd11eb..2087f223 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,6 +1,6 @@ # CodeWhale -> **DeepSeek 优先、面向开源与开放权重编码模型的终端原生编程智能体:100 万 token 上下文、思考模式流式推理、前缀缓存感知。自包含 Rust 二进制发布——开箱即带 MCP 客户端、沙箱和持久化任务队列。** +> **DeepSeek V4 的最强智能体运行框架。规则、工具、证据和反馈循环——帮助模型持续工作直到任务完成,并且越用越好。** [![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/codewhale)](https://www.npmjs.com/package/codewhale) @@ -70,37 +70,35 @@ cargo install codewhale-tui --locked --force ## 这是什么? -codewhale 是一个完全运行在终端里的编程智能体。它让 DeepSeek 前沿模型直接访问你的工作区:读写文件、运行 shell 命令、搜索浏览网页、管理 git、调度子智能体——全部通过快速、键盘驱动的 TUI 完成。 +模型回答问题。智能体完成任务。区别在于运行框架——包围模型的规则、工具、证据和反馈循环。 -它面向 **DeepSeek V4**(`deepseek-v4-pro` / `deepseek-v4-flash`)构建,原生支持 100 万 token 上下文窗口和思考模式流式输出。 +CodeWhale 就是这套框架,围绕 DeepSeek V4 Pro 和 Flash 构建。它最初是一个个人工具,因为维护者受够了模型在任务中途迷失方向、服从过时指令而非用户当前请求、或者命令失败就放弃。结果诞生了一个让模型保持方向的系统:宪政提示层级、结构化信任边界、并行子智能体、前缀缓存感知的上下文管理、以及让模型有足够信号来自我校正的验证节拍。 -### 主要功能 +DeepSeek V4 参与了这套框架的部分编写。这很重要——它意味着 CodeWhale 已经是使用 V4 最有效的方式,并且随着 V4 的改进,框架也会随之改进。每一轮都留下更好的提示、更好的规则、更好的交接。下一轮从一个更强的位置开始。 -- **模型自动路由** —— `--model auto` / `/model auto` 每轮自动选择模型和推理强度 -- **Fin 快速通道** —— 使用关闭思考的低成本 `deepseek-v4-flash` 承担路由、RLM 子调用、摘要和协调工作 -- **原生 RLM**(`rlm_open`/`rlm_eval`)—— 持久化 REPL 会话用于批量分析;使用带界面的辅助函数(`peek`、`search`、`chunk`、`sub_query_batch`) -- **思考模式流式输出** —— 实时观察模型在解决问题时的思维链展开 -- **完整工具集** —— 文件操作、shell 执行、git、网页搜索/浏览、apply-patch、子智能体、MCP 服务器 -- **100 万 token 上下文** —— 上下文跟踪、手动或配置驱动的压缩,以及前缀缓存遥测 -- **前缀缓存稳定性跟踪** —— 可选 `/statusline` footer chip 显示最近轮次缓存前缀的稳定程度 -- **三种交互模式** —— Plan(只读探索)、Agent(带审批的默认交互)、YOLO(可信工作区自动批准) -- **推理强度档位** —— 用 `Shift+Tab` 在 `off → high → max` 之间切换 -- **会话保存和恢复** —— 长任务的断点续作 -- **工作区回滚** —— 通过 side-git 记录每轮前后快照,支持 `/restore` 和 `revert_turn`,不影响项目自己的 `.git` -- **持久化任务队列** —— 后台任务在重启后仍然存在,支持计划任务和长时间运行的操作 -- **HTTP/SSE 运行时 API** —— `codewhale serve --http` 用于无界面智能体流程 -- **MCP 协议** —— 连接 Model Context Protocol 服务器扩展工具,见 [docs/MCP.md](docs/MCP.md) -- **LSP 诊断** —— 每次编辑后通过 rust-analyzer、pyright、typescript-language-server、gopls、clangd 提供内联错误/警告 -- **用户记忆** —— 可选的持久化笔记文件注入系统提示,实现跨会话偏好保持 -- **多语言 UI** —— 支持 `en`、`ja`、`zh-Hans`、`pt-BR`,支持自动检测 -- **实时成本跟踪** —— 按轮次和会话统计 token 用量与成本估算,含缓存命中/未命中明细;简体中文 locale 下显示 CNY -- **技能系统** —— 可通过 GitHub 安装的组合式指令包;首次启动自带 `skill-creator`、`mcp-builder`、`documents`、`presentations`、`spreadsheets`、`pdf`、`feishu` 等 starter skills -- **终端原生通知** —— OSC 9、OSC 99、OSC 777,以及桌面通知兜底 -- **内置主题选择器** —— Catppuccin、Tokyo Night、Dracula、Gruvbox 和原有亮/暗色主题,可用 `/theme` 实时切换 +### 框架如何工作 + +**宪政层级。** 系统提示词是一个冲突法引擎。用户意图优先于陈旧记忆。实时证据优先于假设。验证优先于自信。每一轮继承清晰的权威链,模型永远不需要猜测该服从哪条指令。 + +**结构化信任。** 三种模式,硬边界——Plan(只读)、Agent(审批门控)、YOLO(可信工作区自动批准)。OS 级沙箱支撑边界:macOS 的 Seatbelt、Linux 的 Landlock、Windows 的 Job Objects。危险命令无论在哪种模式下都被分类和门控。 + +**反馈节拍。** 失败的命令、失败的测试、LSP 诊断——这些不是死胡同。它们是模型可以调谐的信号。非零退出码是信息。类型错误是修正向量。框架让失败变得可读,模型可以在用户不必不断重新掌舵的情况下恢复。 + +**连续性。** 记忆跨会话持久化。交接在上下文压缩后存活。会话可以保存、恢复和分叉到兄弟路径。下一位智能体——人或机器——从上一位置继续,无需重新发现已经学到的东西。 + +**分布式工作。** 子智能体并发运行,一次最多 20 个。`agent_open` 立即返回,父进程继续工作。结果以结构化完成事件形式到达,带有有界句柄——无需用完整对话记录淹没父上下文。详见 [docs/SUBAGENTS.md](docs/SUBAGENTS.md)。 + +**适度智能。** Fin——关闭思考的廉价 Flash——处理模型自动路由、RLM 子调用、摘要和协调。Pro 用于架构、调试和安全审查。`--model auto` 每轮选择模型和思考强度。每个问题匹配恰当的智能量。 + +**长程注意力经济学。** 100 万 token 上下文窗口,前缀缓存遥测。缓存命中比未命中便宜约 100 倍。`/statusline` 芯片实时显示前缀稳定性,让你能看到某次变更是否即将破坏缓存预算。 + +**自由与恢复。** 每轮记录 side-git 快照,不影响仓库 `.git`。`/restore` 和 `revert_turn` 即刻回滚工作区。危险操作在 OS 级沙箱化。你可以让模型放手尝试。 + +其余功能面:**RLM 会话**(持久化 Python REPL,配合 `peek`、`search`、`chunk`、`sub_query_batch` 辅助函数进行批量分析)、**LSP 诊断**(每次编辑后 rust-analyzer、pyright、tsserver、gopls、clangd 的内联错误)、**MCP 协议**、**HTTP/SSE 运行时 API**、重启后仍存活的**持久化任务队列**、Zed 等编辑器的 **ACP 适配器**、**SWE-bench 导出**、**可安装技能**、**主题选择器**、**桌面通知**、以及带缓存命中/未命中明细的**实时成本追踪**。 --- -## 架构说明 +## 运行框架 `codewhale`(调度器 CLI)→ `codewhale-tui`(伴随二进制)→ ratatui 界面 ↔ 异步引擎 ↔ OpenAI 兼容流式客户端。工具调用通过类型化注册表(shell、文件操作、git、web、子智能体、MCP、RLM)路由,结果流式返回对话记录。引擎管理会话状态、轮次追踪、持久化任务队列和 LSP 子系统——它在下一步推理前将编辑后诊断反馈到模型上下文中。 diff --git a/web/app/[locale]/docs/page.tsx b/web/app/[locale]/docs/page.tsx index 5a639add..cfe384b5 100644 --- a/web/app/[locale]/docs/page.tsx +++ b/web/app/[locale]/docs/page.tsx @@ -121,7 +121,7 @@ export default async function DocsPage({ params }: { params: Promise<{ locale: s { group: "Git / 诊断 / 测试", tools: "git_status · git_diff · diagnostics · run_tests" }, { group: "子 Agent", tools: "agent_open · agent_eval · agent_close —— 持久会话,并行执行,通过 var_handle 读取大结果" }, { group: "递归 LM (RLM)", tools: "rlm_open · rlm_eval · rlm_configure · rlm_close —— 沙箱 Python REPL,内置 peek/search/chunk/sub_query_batch 等辅助函数" }, - { group: "MCP", tools: "mcp__——从 ~/.deepseek/mcp.json 自动注册" }, + { group: "MCP", tools: "mcp__——从 ~/.codewhale/mcp.json 自动注册" }, ].map((row) => (
{row.group}
@@ -152,8 +152,8 @@ export default async function DocsPage({ params }: { params: Promise<{ locale: s ))}

- 沙箱:{facts.sandboxBackends.join("、")}。Windows 当前不宣称 OS 级文件系统沙箱,但保留同样的审批、工作区边界和终端运行时保护。 - /trust 可解除工作区边界限制。 + 沙箱:{facts.sandboxBackends.join("、")}。工作区边界默认为 --workspace。 + /trust 可解除边界限制。

@@ -163,7 +163,7 @@ export default async function DocsPage({ params }: { params: Promise<{ locale: s 配置 Configuration
-{`# ~/.deepseek/config.toml
+{`# ~/.codewhale/config.toml
 api_key = "sk-..."
 base_url = "https://api.deepseek.com"
 default_text_model = "${facts.defaultModel ?? "deepseek-v4-pro"}"  # 默认模型;deepseek-v4-flash 用于快速 / 子智能体
@@ -179,7 +179,7 @@ default_timeout_secs = 30
 
 [[hooks.hooks]]
 event = "session_start"                     # 也支持: tool_call_before / tool_call_after
-command = "~/.deepseek/hooks/pre.sh"         # / message_submit / mode_change / on_error / shell_env`}
+command = "~/.codewhale/hooks/pre.sh"        # / message_submit / mode_change / on_error / shell_env`}
                 

完整参考:config.example.toml。 @@ -193,7 +193,7 @@ command = "~/.deepseek/hooks/pre.sh" # / message_submit / mode_change /

codewhale 双向支持模型上下文协议(Model Context Protocol):作为客户端从 - ~/.deepseek/mcp.json 加载服务器,同时也可作为服务器暴露工具 + ~/.codewhale/mcp.json 加载服务器,同时也可作为服务器暴露工具 (codewhale mcp)。工具以 mcp_<server>_<tool> 形式呈现。

@@ -218,7 +218,7 @@ command = "~/.deepseek/hooks/pre.sh"         # / message_submit / mode_change /
                   技能 Skills
                 
                 

- 技能是 ~/.deepseek/skills/<name>/ 下的一个文件夹, + 技能是 ~/.codewhale/skills/<name>/ 下的一个文件夹, 根目录包含 SKILL.md。Agent 启动时加载技能名称和描述, 在需要时通过 Skill 工具拉取完整内容。

@@ -253,7 +253,7 @@ command = "~/.deepseek/hooks/pre.sh" # / message_submit / mode_change /

使用 codewhale auth set --provider <id> 切换。下表为 crates/tui/src/config.rsApiProvider 枚举的实时投影 - ,v0.8.45 当前共 {facts.providers.length} 个。 + ,目前共 {facts.providers.length} 个。

{facts.providers.map((p) => ( @@ -265,13 +265,9 @@ command = "~/.deepseek/hooks/pre.sh" # / message_submit / mode_change / ))}

- 开放模型平台方向:CodeWhale 保持 DeepSeek 优先,同时内置 Moonshot/Kimi、OpenRouter、NVIDIA NIM、 - AtlasCloud、Wanjie Ark、Novita、Fireworks 和自托管 SGLang/vLLM/Ollama 路径。 - Kimi Code 会员 API Key 使用 providers.moonshot.base_url - 指向 https://api.kimi.com/coding/v1,模型为 - kimi-for-coding;Kimi/Moonshot 平台 API Key 继续使用 - https://api.moonshot.ai/v1 和 - kimi-k2.6。 + 开放模型平台方向:CodeWhale 正在扩展对 + OpenRouter Hugging Face 自托管 模型的支持, + 为您提供完全自主的模型选择——从云端 API 到本地部署均可覆盖。

@@ -377,7 +373,7 @@ command = "~/.deepseek/hooks/pre.sh" # / message_submit / mode_change / { group: "Git / diag / test", tools: "git_status · git_diff · diagnostics · run_tests" }, { group: "Sub-agents", tools: "agent_open · agent_eval · agent_close — persistent sessions, parallel execution, bounded result retrieval via var_handle" }, { group: "Recursive LM (RLM)", tools: "rlm_open · rlm_eval · rlm_configure · rlm_close — sandboxed Python REPL with peek/search/chunk/sub_query_batch helpers" }, - { group: "MCP", tools: "mcp__ — auto-registered from ~/.deepseek/mcp.json" }, + { group: "MCP", tools: "mcp__ — auto-registered from ~/.codewhale/mcp.json" }, ].map((row) => (
{row.group}
@@ -407,9 +403,8 @@ command = "~/.deepseek/hooks/pre.sh" # / message_submit / mode_change / ))}

- Sandbox: {facts.sandboxBackends.join(", ")}. On Windows, CodeWhale does not advertise - OS-level filesystem isolation yet, but keeps the same approvals, workspace boundary, - and terminal runtime protections. /trust lifts the workspace boundary. + Sandbox: {facts.sandboxBackends.join(", ")}. Workspace boundary defaults to{" "} + --workspace. /trust lifts the boundary.

@@ -418,7 +413,7 @@ command = "~/.deepseek/hooks/pre.sh" # / message_submit / mode_change / Configuration 配置
-{`# ~/.deepseek/config.toml
+{`# ~/.codewhale/config.toml
 api_key = "sk-..."
 base_url = "https://api.deepseek.com"
 default_text_model = "${facts.defaultModel ?? "deepseek-v4-pro"}"  # default; deepseek-v4-flash is the fast / sub-agent option
@@ -434,7 +429,7 @@ default_timeout_secs = 30
 
 [[hooks.hooks]]
 event = "session_start"                     # or: tool_call_before / tool_call_after
-command = "~/.deepseek/hooks/pre.sh"         # / message_submit / mode_change / on_error / shell_env`}
+command = "~/.codewhale/hooks/pre.sh"        # / message_submit / mode_change / on_error / shell_env`}
                 

Full reference: config.example.toml. @@ -447,7 +442,7 @@ command = "~/.deepseek/hooks/pre.sh" # / message_submit / mode_change /

codewhale speaks the Model Context Protocol both ways: as a client (loads - servers from ~/.deepseek/mcp.json) and as a server + servers from ~/.codewhale/mcp.json) and as a server (codewhale mcp). Tools surface as mcp_<server>_<tool>.

@@ -471,7 +466,7 @@ command = "~/.deepseek/hooks/pre.sh"         # / message_submit / mode_change /
                   Skills 技能
                 
                 

- A skill is a folder under ~/.deepseek/skills/<name>/ + A skill is a folder under ~/.codewhale/skills/<name>/ with a SKILL.md at the root. The agent loads skill names + descriptions on startup and can pull in the full body via the Skill tool when relevant.

@@ -505,7 +500,7 @@ command = "~/.deepseek/hooks/pre.sh" # / message_submit / mode_change /

Switch with codewhale auth set --provider <id>. The table below is a live projection of the ApiProvider enum - in crates/tui/src/config.rs — v0.8.45 currently has {facts.providers.length} providers. + in crates/tui/src/config.rs — currently {facts.providers.length} providers.

{facts.providers.map((p) => ( @@ -517,14 +512,9 @@ command = "~/.deepseek/hooks/pre.sh" # / message_submit / mode_change / ))}

- Open-model platform direction: CodeWhale stays DeepSeek-first while shipping Moonshot/Kimi, - OpenRouter, NVIDIA NIM, AtlasCloud, Wanjie Ark, Novita, Fireworks, and self-hosted - SGLang/vLLM/Ollama paths. - Kimi Code membership API keys use providers.moonshot.base_url - set to https://api.kimi.com/coding/v1 with - kimi-for-coding; Kimi/Moonshot Platform API keys use - https://api.moonshot.ai/v1 with - kimi-k2.6. + Open-model platform direction: CodeWhale is expanding support for + OpenRouter, Hugging Face, and self-hosted models, + giving you full sovereignty over model choice — from cloud APIs to local deployments.

@@ -557,4 +547,4 @@ command = "~/.deepseek/hooks/pre.sh" # / message_submit / mode_change / )} ); -} +} \ No newline at end of file diff --git a/web/app/[locale]/faq/page.tsx b/web/app/[locale]/faq/page.tsx index 2d9db748..f216552a 100644 --- a/web/app/[locale]/faq/page.tsx +++ b/web/app/[locale]/faq/page.tsx @@ -23,7 +23,7 @@ const faqEn: FaqItem[] = [ q: "What is CodeWhale?", a: ( <> - CodeWhale is a terminal-native coding agent for open-source and open-weight models. It runs from the codewhale command, streams reasoning blocks, edits local workspaces with approval gates, and can auto-route each turn to the right model and thinking level. DeepSeek V4 is the first-class model path; v0.8.45 also ships Moonshot/Kimi, OpenRouter, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, Novita, Fireworks, SGLang, vLLM, and Ollama paths. + CodeWhale is a terminal-native coding agent for open-source and open-weight models. It runs from the codewhale command, streams reasoning blocks, edits local workspaces with approval gates, and can auto-route each turn to the right model and thinking level. DeepSeek V4 is the first-class model path; OpenRouter is ready. Hugging Face, self-hosted, and other open-model surfaces are on the roadmap. ), sources: ["README.md", "docs/ARCHITECTURE.md"], @@ -112,13 +112,12 @@ codewhale doctor # full connectivity check`}

CodeWhale ships with these built-in providers:

  • DeepSeek — first-class, native API. Reasoning streaming, cache metrics, thinking effort control.
  • -
  • Moonshot/Kimi — Kimi Code and Kimi/Moonshot Platform API-key modes.
  • OpenRouter — unified API for DeepSeek models and more.
  • -
  • OpenAI-compatible, NVIDIA NIM, AtlasCloud, Wanjie Ark, Novita, Fireworks, SGLang, vLLM, Ollama
  • +
  • OpenAI, NVIDIA NIM, Novita, Fireworks, sglang, vLLM, Ollama

Set the corresponding env var (e.g. OPENROUTER_API_KEY) and your provider in ~/.deepseek/config.toml. - SGLang, vLLM, and Ollama can also run against self-hosted OpenAI-compatible endpoints. + Hugging Face, ZenMux, and self-hosted OpenAI-compatible endpoints are on the roadmap.

), @@ -157,7 +156,7 @@ default_text_model = "openrouter/deepseek/deepseek-v4-pro"`} Yes. Use the vllm, sglang, or ollama providers with your local endpoint. For OpenAI-compatible endpoints (llama.cpp server, text-generation-webui, Aphrodite, etc.), you can use the openai provider with a custom base_url. CodeWhale also respects DEEPSEEK_ALLOW_INSECURE_HTTP=true for local HTTP endpoints. - Direct Hugging Face TGI discovery remains roadmap work. + Full Hugging Face TGI/vLLM integration is on the roadmap. ), sources: ["#574", "#1303", "docs/CONFIGURATION.md"], @@ -212,7 +211,7 @@ default_text_model = "openrouter/deepseek/deepseek-v4-pro"`} a: ( <> CodeWhale runs entirely on your machine. No telemetry, no cloud processing of your code. - Sandbox backends: seatbelt (macOS), landlock (Linux). Windows keeps the same approval and terminal runtime protections, but does not advertise OS-level filesystem isolation yet. + Sandbox backends: seatbelt (macOS), landlock (Linux), restricted tokens (Windows). Workspace boundaries default to --workspace. /trust lifts them. Approval mode is configurable per session. All credential/approval/elevation events are written to ~/.deepseek/audit.log. @@ -337,7 +336,7 @@ const faqZh: FaqItem[] = [ q: "CodeWhale 是什么?", a: ( <> - CodeWhale 是一个面向开源模型的终端原生编程智能体。通过 codewhale 命令启动,流式输出推理块,在有审批门槛的情况下编辑本地工作区,并可为每个回合自动选择最合适的模型和推理深度。DeepSeek V4 是一级模型路径;v0.8.45 也内置 Moonshot/Kimi、OpenRouter、NVIDIA NIM、OpenAI 兼容、AtlasCloud、Wanjie Ark、Novita、Fireworks、SGLang、vLLM 和 Ollama 路径。 + CodeWhale 是一个面向开源模型的终端原生编程智能体。通过 codewhale 命令启动,流式输出推理块,在有审批门槛的情况下编辑本地工作区,并可为每个回合自动选择最合适的模型和推理深度。DeepSeek V4 是一级模型路径;OpenRouter 已就绪。Hugging Face、自托管等开放模型接口已在路线图中。 ), sources: ["README.md", "docs/ARCHITECTURE.md"], @@ -425,13 +424,12 @@ codewhale doctor # 完整连接检查`}

CodeWhale 内建以下提供商:

  • DeepSeek — 一级支持,原生 API。推理流、缓存指标、思考力度控制。
  • -
  • Moonshot/Kimi — Kimi Code 与 Kimi/Moonshot 平台 API key 模式。
  • OpenRouter — 统一 API,可访问 DeepSeek 等模型。
  • -
  • OpenAI 兼容NVIDIA NIMAtlasCloudWanjie ArkNovitaFireworksSGLangvLLMOllama
  • +
  • OpenAINVIDIA NIMNovitaFireworkssglangvLLMOllama

设置对应的环境变量(如 OPENROUTER_API_KEY)并在 ~/.deepseek/config.toml 中配置你的提供商。 - SGLang、vLLM 和 Ollama 也可以连接自托管 OpenAI 兼容端点。 + Hugging Face、ZenMux 和自托管 OpenAI 兼容端点正在路线图中。

), @@ -470,7 +468,7 @@ default_text_model = "openrouter/deepseek/deepseek-v4-pro"`} 可以。使用 vllmsglangollama 提供商连接本地端点。 对于 OpenAI 兼容端点(llama.cpp server、text-generation-webui 等),可以使用 openai 提供商并设置自定义 base_url。 CodeWhale 也支持 DEEPSEEK_ALLOW_INSECURE_HTTP=true 用于本地 HTTP 端点。 - Hugging Face TGI 的直接发现仍在路线图中。 + 完整的 Hugging Face TGI/vLLM 集成正在路线图中。 ), sources: ["#574", "#1303", "docs/CONFIGURATION.md"], @@ -525,7 +523,7 @@ default_text_model = "openrouter/deepseek/deepseek-v4-pro"`} a: ( <> CodeWhale 完全在你的机器上运行。无遥测,不会将你的代码上传到云端处理。 - 沙箱后端:seatbelt(macOS)、landlock(Linux)。Windows 保留同样的审批与终端运行时保护,但当前不宣称 OS 级文件系统沙箱。 + 沙箱后端:seatbelt(macOS)、landlock(Linux)、受限令牌(Windows)。 工作区边界默认为 --workspace/trust 可解除边界。 审批模式可按会话配置。所有凭证/审批/提权事件写入 ~/.deepseek/audit.log。 diff --git a/web/app/[locale]/page.tsx b/web/app/[locale]/page.tsx index 2c999f39..3a7672ac 100644 --- a/web/app/[locale]/page.tsx +++ b/web/app/[locale]/page.tsx @@ -15,7 +15,7 @@ const FALLBACK_STATS: RepoStats = { forks: 0, openIssues: 0, openPulls: 0, - contributors: 99, + contributors: 98, fetchedAt: new Date().toISOString(), }; @@ -87,15 +87,15 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s

{isZh - ? "面向开源模型的终端编程智能体。" - : "The terminal coding agent for open models."} + ? "DeepSeek V4 的最强智能体运行框架。" + : "The most agentic harness for DeepSeek V4."}

CodeWhale {isZh - ? " 是面向 DeepSeek V4 及其他开放权重模型的终端原生编程智能体。它读改文件、跑测试、调用 MCP 服务器,并通过审批、工作区边界和平台沙箱控制风险。" - : " is a terminal-native coding agent for DeepSeek V4 and other open-weight models. It reads and edits files, runs tests, calls MCP servers, and controls risk through approvals, workspace boundaries, and platform sandboxes."} + ? " 是围绕 DeepSeek V4 Pro 和 Flash 构建的运行框架。规则、工具、证据和反馈循环——帮助模型持续工作,并且不断进步。DeepSeek V4 参与了部分编写。更好的框架让 V4 更有效,更有效的 V4 又让框架变得更好——这是个正向循环。" + : " is a harness built around DeepSeek V4 Pro and Flash. Rules, tools, evidence, and feedback loops that help the model keep working — and keep improving. DeepSeek V4 helped write parts of it. A better harness makes V4 more effective, and a more effective V4 makes the harness better — it loops."}

@@ -155,7 +155,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s )}
- {isZh ? "Linux / macOS / Windows x64" : "Linux / macOS / Windows x64"} + {isZh ? "需要 Node 或 Rust 1.88+" : "needs Node or Rust 1.88+"} {isZh ? "其他方式 →" : "other ways →"}
@@ -178,48 +178,48 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s {isZh ? ( <>
-
01 · 终端智能体
-

编程智能体,不是聊天框

+
01 · 宪政层级
+

用户意图高于一切

- 与 Claude Code、Codex CLI 相同的循环:读、改、跑测试、回报。键盘驱动,住在终端里。 + 实时证据高于假设。验证高于自信。清晰的权威链让模型无需猜测该服从哪条指令。

-
02 · 开源模型优先
-

DeepSeek V4 深度集成

+
02 · 反馈驱动
+

失败是信号,不是终点

- 原生 DeepSeek API:推理流、缓存指标、思考力度控制。Moonshot/Kimi、OpenRouter、NVIDIA NIM、vLLM、SGLang 等同时可选。 + 失败的命令、失败的测试、LSP 错误。框架让失败可读。每一次节拍都是模型可以调谐的信息,逐轮迭代。

-
03 · 沙箱边界
-

Plan、Agent、YOLO

+
03 · 自我修正
+

恢复内建于环境

- Plan 只读;Agent 风险操作前确认;YOLO 全自动。macOS 使用 seatbelt,Linux 使用 landlock;Windows 保留同样的审批与终端保护。 + 子智能体、回滚、会话分叉、交接。模型不需要一次就全对。恢复机制从底层支持试错。

) : ( <>
-
01 · terminal agent
-

A coding agent, not a chat box

+
01 · constitutional
+

User intent above everything

- Same loop as Claude Code or Codex CLI: reads, edits, runs tests, reports back. Keyboard-driven, lives in your terminal. + Live evidence above assumptions. Verification above confidence. A clear chain of authority so the model never guesses which instruction to follow.

-
02 · open models first
-

DeepSeek V4, deeply integrated

+
02 · feedback-driven
+

Failure is signal, not a dead end

- Native DeepSeek API: reasoning streaming, cache metrics, thinking-effort control. Moonshot/Kimi, OpenRouter, NVIDIA NIM, vLLM, and SGLang are also supported. + Failed commands, failing tests, LSP errors. The harness makes failure legible. Each beat is information the model can tune against, turn after turn.

-
03 · controlled
-

Plan, Agent, YOLO

+
03 · self-correcting
+

Recovery built into the environment

- Plan reads only. Agent asks before risky ops. YOLO auto-approves. macOS uses seatbelt, Linux uses landlock; Windows keeps the same approval and terminal protections. + Sub-agents, rollback, session forks, handoffs. The model doesn't have to get everything right the first time. Recovery is built into the environment.

@@ -260,6 +260,11 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s ? "100:1 不是性能基准,而是贡献形状:一个提示词、许多智能体小时、一个小补丁、一次维护者审查。" : "100-to-1 is not a throughput benchmark. It is a contribution shape: one prompt, many agent-hours, one small patch, one maintainer review."}

+

+ {isZh + ? "框架承担了繁重工作:宪政提示、结构化信任、反馈循环和跨会话存活的交接。模型可以专注于任务本身。因为 DeepSeek V4 参与构建了这套框架,每一次改进都让 V4 在其中变得更有效——这让下一次改进变得更容易。" + : "The harness does the heavy lifting: constitutional prompts, structured trust, feedback loops, and handoffs that survive the session. The model is free to focus on the task. And because DeepSeek V4 helped build this harness, each improvement makes V4 more effective within it — which makes the next improvement easier."} +

{isZh ? "运行提示词 →" : "Run the prompt →"} @@ -325,7 +330,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s B -->|tool call| T["read_file · edit_file · grep
apply_patch · exec_shell
mcp_<server>_<tool>"] T -->|approval Y/N| P["审批对话框
approval dialog"] P --> B - T -->|exec| S["平台控制
seatbelt · landlock · approvals"] + T -->|exec| S["沙箱
seatbelt · landlock · win32"] classDef accent fill:#e9eefe,stroke:#0e0e10,stroke-width:1px; classDef api fill:#0e0e10,stroke:#0e0e10,color:#ffffff; class C api; @@ -337,7 +342,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s B -->|tool call| T["read_file · edit_file · grep
apply_patch · exec_shell
mcp_<server>_<tool>"] T -->|approval Y/N| P["Approval
dialog"] P --> B - T -->|exec| S["Platform controls
seatbelt · landlock · approvals"] + T -->|exec| S["Sandbox
seatbelt · landlock · win32"] classDef accent fill:#e9eefe,stroke:#0e0e10,stroke-width:1px; classDef api fill:#0e0e10,stroke:#0e0e10,color:#ffffff; class C api; diff --git a/web/app/[locale]/roadmap/page.tsx b/web/app/[locale]/roadmap/page.tsx index 3bcc5a46..17e382bb 100644 --- a/web/app/[locale]/roadmap/page.tsx +++ b/web/app/[locale]/roadmap/page.tsx @@ -26,12 +26,12 @@ const tracksEn = [ { title: "Sub-agent parallel execution", note: "agent_open / agent_eval / agent_close; up to 10 concurrent sessions with bounded result handles" }, { title: "RLM batched processing", note: "Persistent sandboxed Python REPL with 1–16 cheap parallel children for long-input analysis" }, { title: "Three operating modes", note: "Plan (read-only), Agent (default), YOLO (auto-approved); orthogonal suggest / auto / never approval" }, - { title: "Per-platform controls", note: "seatbelt (macOS), landlock (Linux); Windows keeps approvals and terminal/runtime protections while OS sandbox work remains tracked" }, + { title: "Per-platform sandbox", note: "seatbelt (macOS), landlock (Linux); Windows containment via restricted tokens (limited)" }, { title: "Durable sessions + tasks", note: "Save, resume, rollback; background task queue with replayable timelines under ~/.deepseek/tasks/" }, - { title: "Bidirectional MCP", note: "Consume tools from external servers; expose as server via `codewhale mcp`; ~/.deepseek/mcp.json" }, + { title: "Bidirectional MCP", note: "Consume tools from external servers; expose as server via `deepseek mcp`; ~/.deepseek/mcp.json" }, { title: "Skills + unified slash palette", note: "~/.deepseek/skills/ auto-loading; /help, /mode, /status, /config, /trust, /feedback" }, - { title: "v0.8.45 provider surface", note: "DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, OpenRouter, Novita, Fireworks, Moonshot/Kimi, SGLang, vLLM, and Ollama" }, - { title: "Moonshot/Kimi API-key setup", note: "Kimi Code plan and Kimi/Moonshot Platform API-key paths for Moonshot/Kimi sessions" }, + { title: "OpenRouter provider", note: "First-class OpenRouter integration with 300+ models across dozens of providers" }, + { title: "Multi-provider support", note: "Hot-swap between providers (DeepSeek, OpenAI, Anthropic, OpenRouter) per session" }, ], }, { @@ -43,8 +43,8 @@ const tracksEn = [ { title: "Memory typed store", note: "SQLite + FTS5 backend with graph-structured agent memory and multi-signal recall (#534–#536)" }, { title: "Feishu / Lark bot", note: "Chat-platform frontend over the existing runtime API (#757)" }, { title: "Chinese-market & i18n", note: "Locale-aware UI, platform refinements, region-specific search backends (#755)" }, - { title: "Model Lab", note: "Curated model discovery and benchmarking for open-weight and self-hosted workflows" }, - { title: "Provider billing and catalogs", note: "/balance capability layer plus richer live model catalogs for providers that expose listing endpoints" }, + { title: "Hugging Face model discovery + Model Lab", note: "Browse, download, and manage models from Hugging Face Hub directly in the TUI" }, + { title: "ZenMux / OpenAI-compatible providers", note: "Bring any OpenAI-compatible endpoint (vLLM, LiteLLM, Ollama, local) as a first-class provider" }, ], }, { @@ -92,12 +92,12 @@ const tracksZh = [ { title: "子 Agent 并行执行", note: "agent_open / agent_eval / agent_close;最多 10 个并发会话,通过 var_handle 有界读取结果" }, { title: "RLM 批量处理", note: "持久沙箱 Python REPL,支持 1–16 路廉价并行子调用,处理长文本分析" }, { title: "三种运行模式", note: "Plan(只读)、Agent(默认)、YOLO(自动批准);审批模式正交(建议/自动/拒绝)" }, - { title: "跨平台控制", note: "seatbelt(macOS)、landlock(Linux);Windows 保留审批与终端运行时保护,OS 沙箱仍在跟踪中" }, + { title: "跨平台沙箱", note: "seatbelt(macOS)、landlock(Linux);Windows 通过受限令牌实现基础隔离(功能有限)" }, { title: "持久化会话 + 后台任务", note: "保存、恢复、回滚;后台任务队列,可回放时间线,位于 ~/.deepseek/tasks/" }, - { title: "双向 MCP 协议", note: "消费外部服务器工具;通过 `codewhale mcp` 暴露为服务器;~/.deepseek/mcp.json" }, + { title: "双向 MCP 协议", note: "消费外部服务器工具;通过 `deepseek mcp` 暴露为服务器;~/.deepseek/mcp.json" }, { title: "技能 + 统一命令面板", note: "~/.deepseek/skills/ 自动加载;/help、/mode、/status、/config、/trust、/feedback" }, - { title: "v0.8.45 提供商表面", note: "DeepSeek、NVIDIA NIM、OpenAI 兼容、AtlasCloud、Wanjie Ark、OpenRouter、Novita、Fireworks、Moonshot/Kimi、SGLang、vLLM、Ollama" }, - { title: "Moonshot/Kimi API-key 设置", note: "Kimi Code 会员与 Kimi/Moonshot 平台 API key 路径" }, + { title: "OpenRouter 提供商", note: "原生集成 OpenRouter,支持 300+ 模型,覆盖数十个提供商" }, + { title: "多提供商支持", note: "按会话动态切换提供商(DeepSeek、OpenAI、Anthropic、OpenRouter)" }, ], }, { @@ -109,8 +109,8 @@ const tracksZh = [ { title: "记忆类型化存储", note: "SQLite + FTS5 后端,图结构 Agent 记忆,多信号召回(#534–#536)" }, { title: "飞书 / Lark 机器人", note: "基于现有 runtime API 的聊天平台前端(#757)" }, { title: "中国市场与国际化改进", note: "本地化 UI、平台优化、区域搜索引擎(#755)" }, - { title: "模型实验室", note: "面向开放权重和自托管工作流的模型发现与基准测试" }, - { title: "提供商账单与目录", note: "/balance 能力层,以及对支持列表接口的提供商提供更完整的实时模型目录" }, + { title: "Hugging Face 模型发现 + 模型实验室", note: "在 TUI 中直接浏览、下载和管理 Hugging Face Hub 上的模型" }, + { title: "ZenMux / OpenAI 兼容提供商", note: "将任意 OpenAI 兼容端点(vLLM、LiteLLM、Ollama、本地模型)作为一级提供商接入" }, ], }, { @@ -339,4 +339,4 @@ export default async function RoadmapPage({ params }: { params: Promise<{ locale )} ); -} +} \ No newline at end of file diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 9dfc2ab7..214aceef 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -33,13 +33,13 @@ const cjk = Noto_Serif_SC({ }); export const metadata: Metadata = { - title: "CodeWhale · 深度求索 终端", + title: "CodeWhale · DeepSeek V4 智能体运行框架", description: - "Terminal-native coding agent for open-source and open-weight models across providers. DeepSeek V4 is first-class. Community site for installation, docs, roadmap, and live activity.", + "The most agentic harness for DeepSeek V4. Constitutional hierarchy, structured trust, verification, and recovery — rules, tools, and feedback loops that help the model keep working.", metadataBase: new URL("https://codewhale.net"), openGraph: { title: "CodeWhale", - description: "Terminal-native coding agent for open-source and open-weight models across providers.", + description: "The most agentic harness for DeepSeek V4. Constitutional hierarchy, structured trust, verification, and recovery.", url: "https://codewhale.net", siteName: "CodeWhale", type: "website", diff --git a/website/index.html b/website/index.html index 39351284..8cbf59cb 100644 --- a/website/index.html +++ b/website/index.html @@ -3,7 +3,7 @@ - DeepSeek TUI — Terminal-native coding agent + CodeWhale — The most agentic harness for DeepSeek V4 - - - - - -
- -
-
v0.8.45 available now
-

The most agentic harness
for DeepSeek V4

-

Rules, tools, evidence, and feedback loops that help the model keep working until the task is done. 1M-token context, thinking-mode streaming, OS-level sandboxing — single binary, zero dependencies.

- -
-
- - - - bash — zsh -
-
- $ - npm i -g codewhale - - -
-
- - -
- -
- DeepSeek TUI screenshot -
- -
-

How the harness works

-
-
-

Constitutional hierarchy

-

User intent outranks stale memory. Live evidence outranks assumptions. A clear chain of authority every turn.

-
-
-

Structured trust

-

Plan (read-only), Agent (approval-gated), YOLO (auto-approved). OS-level sandboxing backs every boundary.

-
-
-

Feedback beats

-

Failed commands and failing tests are not dead ends — they're signals the model can tune against, turn after turn.

-
-
-

Continuity

-

Memory persists across sessions. Handoffs survive compaction. Save, resume, and fork conversations.

-
-
-

Distributed work

-

Sub-agents run concurrently, up to 20 at a time. Results arrive as structured completion events.

-
-
-

Right-size intelligence

-

Fin handles routing and summaries. Pro engages for architecture and debugging. The right amount of intelligence for each problem.

-
-
-
- -
-

China / mirror-friendly install

-

If downloads from GitHub or npm are slow from mainland China, use one of these paths:

- -
- npm via 淘宝镜像 (fastest) -
npm config set registry https://registry.npmmirror.com
-npm install -g deepseek-tui
-

The npm wrapper itself will still download the binary from GitHub Releases during postinstall. If that step is slow, set a mirror for the binary download:

-
DEEPSEEK_TUI_RELEASE_BASE_URL=https://your-mirror.example.com \
-  npm install -g deepseek-tui
-
- -
- Cargo via 清华 TUNA mirror -

Add to ~/.cargo/config.toml:

-
[source.crates-io]
-replace-with = "tuna"
-
-[source.tuna]
-registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"
-

Then install both binaries:

-
cargo install deepseek-tui-cli --locked   # provides `deepseek`
-cargo install deepseek-tui     --locked   # provides `deepseek-tui`
-deepseek --version
-
- -
- Rustup mirror (for building from source) -
export RUSTUP_DIST_SERVER=https://mirrors.tuna.tsinghua.edu.cn/rustup
-export RUSTUP_UPDATE_ROOT=https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup
-curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
-
- -

Full platform guide: docs/INSTALL.md · 简体中文 README

-
- -
- - - - - - - diff --git a/website/zh/index.html b/website/zh/index.html deleted file mode 100644 index 363ba492..00000000 --- a/website/zh/index.html +++ /dev/null @@ -1,575 +0,0 @@ - - - - - - CodeWhale — DeepSeek V4 的最强智能体运行框架 - - - - - - - -
- -
-
v0.8.45 现已发布
-

面向 DeepSeek V4
的最强智能体运行框架

-

规则、工具、证据和反馈循环——帮助模型持续工作直到任务完成。100 万 token 上下文、思考模式流式推理、OS 级沙箱——单一二进制,零依赖。

- -
-
- - - - bash — zsh -
-
- $ - npm i -g codewhale - - -
-
- - -
- -
- DeepSeek TUI 截图 -
- -
-

框架如何工作

-
-
-

宪政层级

-

用户意图优先于陈旧记忆。实时证据优先于假设。每一轮都有清晰的权威链。

-
-
-

结构化信任

-

Plan(只读)、Agent(审批门控)、YOLO(自动批准)。OS 级沙箱支撑每一个边界。

-
-
-

反馈节拍

-

失败的命令和失败的测试不是死胡同——它们是模型可以逐轮调谐的信号。

-
-
-

连续性

-

记忆跨会话持久化。交接在压缩后仍然存活。会话可保存、恢复和分叉。

-
-
-

分布式工作

-

子智能体并发运行,一次最多 20 个。结果以结构化完成事件形式到达。

-
-
-

适度智能

-

Fin 处理路由和摘要,Pro 负责架构和调试。每个问题匹配恰当的智能量。

-
-
-
- -
-

中国大陆镜像安装指南

-

如果从 GitHub 或 npm 下载较慢,请按以下方式选择最适合你的安装路径:

- -
- npm + 淘宝镜像(推荐,最简单) -

设置 npm 镜像后全局安装:

-
npm config set registry https://registry.npmmirror.com
-npm install -g deepseek-tui
-

npm 包在安装时会通过 postinstall 从 GitHub Releases 下载对应平台的二进制文件。如果这一步也很慢,可以设置二进制下载镜像地址:

-
DEEPSEEK_TUI_RELEASE_BASE_URL=https://your-mirror.example.com \
-  npm install -g deepseek-tui
-
- -
- Cargo + 清华 TUNA 镜像 -

~/.cargo/config.toml 中添加镜像配置:

-
[source.crates-io]
-replace-with = "tuna"
-
-[source.tuna]
-registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"
-

然后安装两个二进制文件(调度器在运行时会自动调用 TUI):

-
cargo install deepseek-tui-cli --locked   # 提供入口命令 deepseek
-cargo install deepseek-tui     --locked   # 提供交互式 TUI 二进制
-deepseek --version
-
- -
- 从源码构建(Rustup 镜像) -

如果还没有安装 Rust,先通过清华镜像安装 rustup:

-
export RUSTUP_DIST_SERVER=https://mirrors.tuna.tsinghua.edu.cn/rustup
-export RUSTUP_UPDATE_ROOT=https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup
-curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
-

配置 Cargo 镜像后从源码构建:

-
git clone https://github.com/Hmbown/CodeWhale.git
-cd CodeWhale
-cargo install --path crates/cli --locked
-cargo install --path crates/tui --locked
-
- -
- 手动下载预编译二进制 -

直接从 GitHub Releases 下载对应平台的二进制文件,放到 PATH 目录即可:

-
mkdir -p ~/.local/bin
-curl -L -o ~/.local/bin/deepseek \
-  https://github.com/Hmbown/CodeWhale/releases/latest/download/deepseek-linux-x64
-curl -L -o ~/.local/bin/deepseek-tui \
-  https://github.com/Hmbown/CodeWhale/releases/latest/download/deepseek-tui-linux-x64
-chmod +x ~/.local/bin/deepseek ~/.local/bin/deepseek-tui
-

macOS 用户将 linux-x64 替换为 macos-arm64macos-x64,并将 sha256sum 替换为 shasum -a 256

-
- -

完整平台安装指南:docs/INSTALL.md · 简体中文 README

-
- -
- - - - - - - From 003a81d37dab75824c6da8c7e21204e816e2ad13 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 11:21:18 -0500 Subject: [PATCH 074/283] test: align macos CI assertions --- crates/tui/src/theme_qa_audit.rs | 16 +++++----- crates/tui/src/tui/app.rs | 54 +++++++++++++++++--------------- 2 files changed, 38 insertions(+), 32 deletions(-) diff --git a/crates/tui/src/theme_qa_audit.rs b/crates/tui/src/theme_qa_audit.rs index a19da7e3..37c1435a 100644 --- a/crates/tui/src/theme_qa_audit.rs +++ b/crates/tui/src/theme_qa_audit.rs @@ -12,7 +12,9 @@ mod tests { use crate::palette::{ CATPPUCCIN_MOCHA_UI_THEME, DRACULA_UI_THEME, GRAYSCALE_UI_THEME, GRUVBOX_DARK_UI_THEME, - LIGHT_UI_THEME, TOKYO_NIGHT_UI_THEME, UI_THEME, UiTheme, + LIGHT_UI_THEME, TOKYO_NIGHT_UI_THEME, UI_THEME, UiTheme, WHALE_ACCENT_ACTION_RGB, + WHALE_ACCENT_PRIMARY_RGB, WHALE_ACCENT_SECONDARY_RGB, WHALE_BG_RGB, WHALE_TEXT_BODY_RGB, + WHALE_TEXT_MUTED_RGB, }; use ratatui::style::Color; @@ -288,30 +290,30 @@ mod tests { fn whale_dark_uses_proposed_palette() { // Issue #2012: verify the default Whale dark uses proposed tokens. let t = UI_THEME; - assert_eq!(rgb(t.surface_bg), Some((13, 21, 37)), "Deep Navy #0D1525"); + assert_eq!(rgb(t.surface_bg), Some(WHALE_BG_RGB), "Deep Navy #0A1120"); assert_eq!( rgb(t.text_body), - Some((246, 242, 232)), + Some(WHALE_TEXT_BODY_RGB), "Whale Ivory #F6F2E8" ); assert_eq!( rgb(t.text_muted), - Some((169, 180, 199)), + Some(WHALE_TEXT_MUTED_RGB), "Mist Gray #A9B4C7" ); assert_eq!( rgb(t.accent_primary), - Some((246, 196, 83)), + Some(WHALE_ACCENT_PRIMARY_RGB), "Signal Gold #F6C453" ); assert_eq!( rgb(t.accent_secondary), - Some((79, 209, 197)), + Some(WHALE_ACCENT_SECONDARY_RGB), "Seafoam #4FD1C5" ); assert_eq!( rgb(t.accent_action), - Some((255, 122, 89)), + Some(WHALE_ACCENT_ACTION_RGB), "Coral Spark #FF7A59" ); assert_eq!(rgb(t.error_fg), Some((255, 92, 122)), "Rose Red #FF5C7A"); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 884242c4..2c3ec0c9 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -3145,13 +3145,10 @@ impl App { self.insert_str(&normalized); } self.paste_burst.clear_after_explicit_paste(); - // Visible-before-submit consolidation: when the post-paste input - // is over the cap, swap it for an @paste-…md mention immediately - // (instead of waiting until the user presses Enter and getting - // surprised by an auto-sent @mention). The same logic runs as a - // safety-net at submit time so any other code path that fills - // self.input above the cap still consolidates rather than - // silently truncating. + // Large pasted input stays editable and visible until submit. The + // submit-time safety net consolidates oversized composer content into + // an @paste-...md mention before dispatch, so no path silently + // truncates user input. // self.consolidate_large_input_if_oversized(); // deferred to submit time } @@ -5324,12 +5321,10 @@ mod tests { } #[test] - fn paste_consolidates_oversized_text_into_paste_file_visibly() { - // Visible-before-submit consolidation (paste UX): when a single - // bracketed paste exceeds the safety cap, the @mention must - // replace the input *immediately*, so the user sees what's - // about to be sent before pressing Enter — not as a side effect - // of submit. + fn paste_defers_oversized_text_consolidation_until_submit() { + // #2168: a large paste stays inline so the user can still edit it. + // Submit-time consolidation then writes the paste file and sends the + // @mention instead of the raw oversized content. let tmp = tempfile::TempDir::new().expect("tempdir"); let mut opts = test_options(false); opts.workspace = tmp.path().to_path_buf(); @@ -5338,26 +5333,35 @@ mod tests { app.insert_paste_text(&full_content); - // Composer should now contain the @mention, not the full text. - assert!( - app.input.starts_with("@.deepseek/pastes/paste-") && app.input.ends_with(".md"), - "expected @mention in composer after large paste, got: {}", - app.input - ); - // The cursor moves to the end of the @mention. + assert_eq!(app.input, full_content); assert_eq!(app.cursor_position, app.input.chars().count()); - // The paste file must exist with the full content. - let rel_path = &app.input[1..]; + let pastes_dir = tmp.path().join(".deepseek/pastes"); + assert!( + !pastes_dir.exists() || std::fs::read_dir(&pastes_dir).unwrap().next().is_none(), + "paste file should not be written before submit" + ); + assert!( + app.status_toasts + .iter() + .all(|toast| !toast.text.contains("consolidated")), + "consolidation toast should not appear before submit" + ); + + let submitted = app.submit_input().expect("expected submitted input"); + assert!( + submitted.starts_with("@.deepseek/pastes/paste-") && submitted.ends_with(".md"), + "expected @mention after submit, got: {submitted}" + ); + let rel_path = &submitted[1..]; let abs = tmp.path().join(rel_path); assert!(abs.is_file(), "paste file must exist at {abs:?}"); let written = std::fs::read_to_string(&abs).expect("read"); assert_eq!(written, full_content); - // A toast confirms what happened so the user isn't surprised. assert!( app.status_toasts .iter() - .any(|t| t.text.contains("consolidated")), - "expected consolidation toast" + .any(|toast| toast.text.contains("consolidated")), + "expected consolidation toast after submit" ); } From 586f3f325e0808f2e14ee9e117187248c5d59c3b Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 11:32:31 -0500 Subject: [PATCH 075/283] test: align windows notification fallback --- crates/tui/src/tui/notifications.rs | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/crates/tui/src/tui/notifications.rs b/crates/tui/src/tui/notifications.rs index d2b068e8..4db152fe 100644 --- a/crates/tui/src/tui/notifications.rs +++ b/crates/tui/src/tui/notifications.rs @@ -8,8 +8,8 @@ //! - **BEL** — audible bell (`\x07`) as a last-resort fallback. //! //! When `method = "auto"`, the resolver picks the best method for the -//! current terminal; Windows falls back to `Off` to avoid the error chime -//! (#583). +//! current terminal; Windows falls back to `Bel`, which is routed through +//! `MessageBeep(MB_OK)` for an audible default notification sound. #[cfg(target_os = "windows")] use windows::Win32::System::Diagnostics::Debug::MessageBeep; @@ -68,7 +68,7 @@ fn windows_bell() { /// - `$TERM` contains `ghostty` → `Osc9` (cmux etc.) /// - `$TERM` contains `kitty` → `Kitty` /// - Unix unknown → `Bel` -/// - Windows unknown → `Off` +/// - Windows unknown → `Bel` #[must_use] fn resolve_method() -> Method { let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default(); @@ -199,8 +199,8 @@ pub fn notify_done_to( /// /// With `method = Auto`, selects the best protocol for the current terminal /// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel). The unknown-terminal -/// fallback is platform-aware — `Bel` on macOS / Linux, `Off` on Windows -/// (where BEL maps to the `SystemAsterisk` / `MB_OK` error chime, #583). +/// fallback is platform-aware: `Bel` on every platform, with Windows routing +/// it through `MessageBeep(MB_OK)` for a default system notification sound. /// See [`resolve_method`] for the canonical resolution table. Pass /// `in_tmux = true` (i.e. `$TMUX` is non-empty at runtime) to wrap OSC /// sequences in a DCS passthrough. @@ -620,7 +620,9 @@ mod tests { /// when the test harness runs them in parallel threads. fn env_lock() -> std::sync::MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) } fn capture( @@ -827,12 +829,11 @@ mod tests { assert_eq!(resolved, Method::Bel); } - /// #583: on Windows, an unknown TERM_PROGRAM resolves to `Off` - /// (not `Bel`) so the post-turn notification doesn't ring the - /// `SystemAsterisk` / `MB_OK` chime. + /// #2166: on Windows, an unknown TERM_PROGRAM resolves to `Bel` so + /// `windows_bell()` can route the notification through `MessageBeep`. #[test] #[cfg(target_os = "windows")] - fn auto_detect_picks_off_for_unknown_on_windows() { + fn auto_detect_picks_bel_for_unknown_on_windows() { let _lock = env_lock(); let prev = std::env::var_os("TERM_PROGRAM"); // SAFETY: test-only; serialised by env_lock(). @@ -845,7 +846,7 @@ mod tests { None => std::env::remove_var("TERM_PROGRAM"), } } - assert_eq!(resolved, Method::Off); + assert_eq!(resolved, Method::Bel); } /// #583: known OSC-9 terminals must still resolve to `Osc9` on From 2ef1c3666f4e3c7d8bc38c63a3de77bd7abe8063 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 11:52:48 -0500 Subject: [PATCH 076/283] chore: bump release lane to 0.8.46 --- CHANGELOG.md | 5 +++-- Cargo.lock | 28 ++++++++++++++-------------- Cargo.toml | 2 +- crates/agent/Cargo.toml | 2 +- crates/app-server/Cargo.toml | 18 +++++++++--------- crates/cli/Cargo.toml | 14 +++++++------- crates/config/Cargo.toml | 2 +- crates/core/Cargo.toml | 16 ++++++++-------- crates/execpolicy/Cargo.toml | 2 +- crates/hooks/Cargo.toml | 2 +- crates/tools/Cargo.toml | 2 +- crates/tui/CHANGELOG.md | 5 +++-- crates/tui/Cargo.toml | 6 +++--- npm/codewhale/package.json | 4 ++-- npm/deepseek-tui/package.json | 2 +- 15 files changed, 56 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f62e7bfd..6c266c89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,7 +83,7 @@ Thanks to new contributors whose PRs landed in this release: **@zhuangbiaowei** (#2145), **@aboimpinto** (#1872), and continuing contributors **@reidliu41**, **@cyq1017**, **@idling11**, -**@h3c-hexin**, **@wdw8276**, and **@zlh124**. +**@h3c-hexin**, **@wdw8276**, **@zlh124**, and **@jeoor**. ## [0.8.45] - 2026-05-25 @@ -4968,7 +4968,8 @@ Welcome — and thank you. - Hooks system and config profiles - Example skills and launch assets -[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.45...HEAD +[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.46...HEAD +[0.8.46]: https://github.com/Hmbown/CodeWhale/compare/v0.8.45...v0.8.46 [0.8.45]: https://github.com/Hmbown/CodeWhale/compare/v0.8.44...v0.8.45 [0.8.44]: https://github.com/Hmbown/CodeWhale/compare/v0.8.43...v0.8.44 [0.8.43]: https://github.com/Hmbown/CodeWhale/compare/v0.8.42...v0.8.43 diff --git a/Cargo.lock b/Cargo.lock index 7dbc5d6c..83777fd1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -803,7 +803,7 @@ checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" [[package]] name = "codewhale-agent" -version = "0.8.45" +version = "0.8.46" dependencies = [ "codewhale-config", "serde", @@ -811,7 +811,7 @@ dependencies = [ [[package]] name = "codewhale-app-server" -version = "0.8.45" +version = "0.8.46" dependencies = [ "anyhow", "axum", @@ -836,7 +836,7 @@ dependencies = [ [[package]] name = "codewhale-cli" -version = "0.8.45" +version = "0.8.46" dependencies = [ "anyhow", "chrono", @@ -862,7 +862,7 @@ dependencies = [ [[package]] name = "codewhale-config" -version = "0.8.45" +version = "0.8.46" dependencies = [ "anyhow", "codewhale-secrets", @@ -875,7 +875,7 @@ dependencies = [ [[package]] name = "codewhale-core" -version = "0.8.45" +version = "0.8.46" dependencies = [ "anyhow", "chrono", @@ -893,7 +893,7 @@ dependencies = [ [[package]] name = "codewhale-execpolicy" -version = "0.8.45" +version = "0.8.46" dependencies = [ "anyhow", "codewhale-protocol", @@ -902,7 +902,7 @@ dependencies = [ [[package]] name = "codewhale-hooks" -version = "0.8.45" +version = "0.8.46" dependencies = [ "anyhow", "async-trait", @@ -916,7 +916,7 @@ dependencies = [ [[package]] name = "codewhale-mcp" -version = "0.8.45" +version = "0.8.46" dependencies = [ "anyhow", "serde", @@ -925,7 +925,7 @@ dependencies = [ [[package]] name = "codewhale-protocol" -version = "0.8.45" +version = "0.8.46" dependencies = [ "serde", "serde_json", @@ -933,7 +933,7 @@ dependencies = [ [[package]] name = "codewhale-secrets" -version = "0.8.45" +version = "0.8.46" dependencies = [ "dirs", "keyring", @@ -946,7 +946,7 @@ dependencies = [ [[package]] name = "codewhale-state" -version = "0.8.45" +version = "0.8.46" dependencies = [ "anyhow", "chrono", @@ -958,7 +958,7 @@ dependencies = [ [[package]] name = "codewhale-tools" -version = "0.8.45" +version = "0.8.46" dependencies = [ "anyhow", "async-trait", @@ -971,7 +971,7 @@ dependencies = [ [[package]] name = "codewhale-tui" -version = "0.8.45" +version = "0.8.46" dependencies = [ "anyhow", "arboard", @@ -1037,7 +1037,7 @@ dependencies = [ [[package]] name = "codewhale-tui-core" -version = "0.8.45" +version = "0.8.46" [[package]] name = "colorchoice" diff --git a/Cargo.toml b/Cargo.toml index ac727f04..dae89151 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"] resolver = "2" [workspace.package] -version = "0.8.45" +version = "0.8.46" edition = "2024" # Rust 1.88 stabilized `let_chains` in `if`/`while` conditions, which the # codebase relies on extensively. Cargo enforces this so users on older diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index dc402892..4f98a69c 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -7,5 +7,5 @@ repository.workspace = true description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture" [dependencies] -codewhale-config = { path = "../config", version = "0.8.45" } +codewhale-config = { path = "../config", version = "0.8.46" } serde.workspace = true diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index d683abf4..09dd8643 100644 --- a/crates/app-server/Cargo.toml +++ b/crates/app-server/Cargo.toml @@ -10,15 +10,15 @@ description = "Codex-style app-server transport for DeepSeek workspace architect anyhow.workspace = true axum.workspace = true clap.workspace = true -codewhale-agent = { path = "../agent", version = "0.8.45" } -codewhale-config = { path = "../config", version = "0.8.45" } -codewhale-core = { path = "../core", version = "0.8.45" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.45" } -codewhale-hooks = { path = "../hooks", version = "0.8.45" } -codewhale-mcp = { path = "../mcp", version = "0.8.45" } -codewhale-protocol = { path = "../protocol", version = "0.8.45" } -codewhale-state = { path = "../state", version = "0.8.45" } -codewhale-tools = { path = "../tools", version = "0.8.45" } +codewhale-agent = { path = "../agent", version = "0.8.46" } +codewhale-config = { path = "../config", version = "0.8.46" } +codewhale-core = { path = "../core", version = "0.8.46" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.46" } +codewhale-hooks = { path = "../hooks", version = "0.8.46" } +codewhale-mcp = { path = "../mcp", version = "0.8.46" } +codewhale-protocol = { path = "../protocol", version = "0.8.46" } +codewhale-state = { path = "../state", version = "0.8.46" } +codewhale-tools = { path = "../tools", version = "0.8.46" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index e2006806..59d4da18 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -25,13 +25,13 @@ path = "src/bin/deepseek_legacy_shim.rs" anyhow.workspace = true clap.workspace = true clap_complete.workspace = true -codewhale-agent = { path = "../agent", version = "0.8.45" } -codewhale-app-server = { path = "../app-server", version = "0.8.45" } -codewhale-config = { path = "../config", version = "0.8.45" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.45" } -codewhale-mcp = { path = "../mcp", version = "0.8.45" } -codewhale-secrets = { path = "../secrets", version = "0.8.45" } -codewhale-state = { path = "../state", version = "0.8.45" } +codewhale-agent = { path = "../agent", version = "0.8.46" } +codewhale-app-server = { path = "../app-server", version = "0.8.46" } +codewhale-config = { path = "../config", version = "0.8.46" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.46" } +codewhale-mcp = { path = "../mcp", version = "0.8.46" } +codewhale-secrets = { path = "../secrets", version = "0.8.46" } +codewhale-state = { path = "../state", version = "0.8.46" } chrono.workspace = true dirs.workspace = true serde.workspace = true diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 912d5ed8..4fbdb03c 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -8,7 +8,7 @@ description = "Config schema and precedence model for DeepSeek workspace archite [dependencies] anyhow.workspace = true -codewhale-secrets = { path = "../secrets", version = "0.8.45" } +codewhale-secrets = { path = "../secrets", version = "0.8.46" } dirs.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index c9d602f4..45853186 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -9,13 +9,13 @@ description = "Core runtime boundaries for DeepSeek workspace architecture" [dependencies] anyhow.workspace = true chrono.workspace = true -codewhale-agent = { path = "../agent", version = "0.8.45" } -codewhale-config = { path = "../config", version = "0.8.45" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.45" } -codewhale-hooks = { path = "../hooks", version = "0.8.45" } -codewhale-mcp = { path = "../mcp", version = "0.8.45" } -codewhale-protocol = { path = "../protocol", version = "0.8.45" } -codewhale-state = { path = "../state", version = "0.8.45" } -codewhale-tools = { path = "../tools", version = "0.8.45" } +codewhale-agent = { path = "../agent", version = "0.8.46" } +codewhale-config = { path = "../config", version = "0.8.46" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.46" } +codewhale-hooks = { path = "../hooks", version = "0.8.46" } +codewhale-mcp = { path = "../mcp", version = "0.8.46" } +codewhale-protocol = { path = "../protocol", version = "0.8.46" } +codewhale-state = { path = "../state", version = "0.8.46" } +codewhale-tools = { path = "../tools", version = "0.8.46" } serde_json.workspace = true uuid.workspace = true diff --git a/crates/execpolicy/Cargo.toml b/crates/execpolicy/Cargo.toml index 16b09697..acf2ce21 100644 --- a/crates/execpolicy/Cargo.toml +++ b/crates/execpolicy/Cargo.toml @@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace [dependencies] anyhow.workspace = true -codewhale-protocol = { path = "../protocol", version = "0.8.45" } +codewhale-protocol = { path = "../protocol", version = "0.8.46" } serde.workspace = true diff --git a/crates/hooks/Cargo.toml b/crates/hooks/Cargo.toml index 4f657cd0..a6a3600e 100644 --- a/crates/hooks/Cargo.toml +++ b/crates/hooks/Cargo.toml @@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc anyhow.workspace = true async-trait.workspace = true chrono.workspace = true -codewhale-protocol = { path = "../protocol", version = "0.8.45" } +codewhale-protocol = { path = "../protocol", version = "0.8.46" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 464ce47e..2be5cc0d 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral [dependencies] anyhow.workspace = true async-trait.workspace = true -codewhale-protocol = { path = "../protocol", version = "0.8.45" } +codewhale-protocol = { path = "../protocol", version = "0.8.46" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index f62e7bfd..6c266c89 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -83,7 +83,7 @@ Thanks to new contributors whose PRs landed in this release: **@zhuangbiaowei** (#2145), **@aboimpinto** (#1872), and continuing contributors **@reidliu41**, **@cyq1017**, **@idling11**, -**@h3c-hexin**, **@wdw8276**, and **@zlh124**. +**@h3c-hexin**, **@wdw8276**, **@zlh124**, and **@jeoor**. ## [0.8.45] - 2026-05-25 @@ -4968,7 +4968,8 @@ Welcome — and thank you. - Hooks system and config profiles - Example skills and launch assets -[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.45...HEAD +[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.46...HEAD +[0.8.46]: https://github.com/Hmbown/CodeWhale/compare/v0.8.45...v0.8.46 [0.8.45]: https://github.com/Hmbown/CodeWhale/compare/v0.8.44...v0.8.45 [0.8.44]: https://github.com/Hmbown/CodeWhale/compare/v0.8.43...v0.8.44 [0.8.43]: https://github.com/Hmbown/CodeWhale/compare/v0.8.42...v0.8.43 diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 2ffdde9e..6aa5486c 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -27,9 +27,9 @@ path = "src/bin/deepseek_tui_legacy_shim.rs" [dependencies] anyhow = "1.0.100" arboard = "3.4" -codewhale-config = { path = "../config", version = "0.8.45" } -codewhale-secrets = { path = "../secrets", version = "0.8.45" } -codewhale-tools = { path = "../tools", version = "0.8.45" } +codewhale-config = { path = "../config", version = "0.8.46" } +codewhale-secrets = { path = "../secrets", version = "0.8.46" } +codewhale-tools = { path = "../tools", version = "0.8.46" } schemaui = { version = "0.12.0", default-features = false, optional = true } async-stream = "0.3.6" async-trait = "0.1" diff --git a/npm/codewhale/package.json b/npm/codewhale/package.json index c8a402f8..5413ba70 100644 --- a/npm/codewhale/package.json +++ b/npm/codewhale/package.json @@ -1,7 +1,7 @@ { "name": "codewhale", - "version": "0.8.45", - "codewhaleBinaryVersion": "0.8.45", + "version": "0.8.46", + "codewhaleBinaryVersion": "0.8.46", "description": "Install and run CodeWhale, the agentic terminal for open-source and open-weight coding models, from GitHub release artifacts.", "author": "Hmbown", "license": "MIT", diff --git a/npm/deepseek-tui/package.json b/npm/deepseek-tui/package.json index b99f9a15..75462031 100644 --- a/npm/deepseek-tui/package.json +++ b/npm/deepseek-tui/package.json @@ -1,6 +1,6 @@ { "name": "deepseek-tui", - "version": "0.8.45", + "version": "0.8.46", "description": "Legacy compatibility package. Renamed to `codewhale`; run `npm install -g codewhale` for new installs.", "author": "Hmbown", "license": "MIT", From e59925f8f1ca1986da5e4f048caa78e36d7242f8 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 12:06:31 -0500 Subject: [PATCH 077/283] =?UTF-8?q?chore:=20finalize=20v0.8.46=20release?= =?UTF-8?q?=20=E2=80=94=20docs=20refresh,=20web=20branding,=20redirect=20w?= =?UTF-8?q?orker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update all three READMEs with binary-pair install instructions - Update INSTALL.md for platform archive + binary pair language - Regenerate facts.generated.ts for v0.8.46 (14 crates, 70 tools) - Rename CF worker project to codewhale-web, add codewhale.net routes - Add web/redirect worker for deepseek-tui.com → codewhale.net --- README.ja-JP.md | 93 +++++++-------------------------- README.md | 100 ++++++------------------------------ README.zh-CN.md | 95 ++++++---------------------------- docs/INSTALL.md | 6 +-- web/lib/facts.generated.ts | 4 +- web/redirect/src/index.ts | 7 +++ web/redirect/wrangler.jsonc | 11 ++++ web/wrangler.jsonc | 8 ++- 8 files changed, 77 insertions(+), 247 deletions(-) create mode 100644 web/redirect/src/index.ts create mode 100644 web/redirect/wrangler.jsonc diff --git a/README.ja-JP.md b/README.ja-JP.md index 75b3ef0e..813cefea 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -1,31 +1,33 @@ # 🐳 CodeWhale -> **このターミナルネイティブのコーディングエージェントは、DeepSeek V4 の 100 万トークンのコンテキストウィンドウとプレフィックスキャッシュ機能を中心に構築されています。単一のバイナリとして配布され、Node.js や Python のランタイムは不要です。MCP クライアント、サンドボックス、永続的なタスクキューも標準で同梱されています。** +> **このターミナルネイティブのコーディングエージェントは、DeepSeek V4 の 100 万トークンのコンテキストウィンドウとプレフィックスキャッシュ機能を中心に構築されています。`codewhale` ディスパッチャーと `codewhale-tui` ランタイムの Rust バイナリペアとして配布され、Node.js や Python のランタイムは不要です。MCP クライアント、サンドボックス、永続的なタスクキューも標準で同梱されています。** [English README](README.md) [简体中文 README](README.zh-CN.md) ## インストール -`codewhale` は自己完結型の Rust バイナリとして提供されており、**実行に Node.js や Python のランタイムは必要ありません。** すでにマシンにインストールされているものを選んでください。いずれの方法でも同じバイナリが `PATH` に配置されます。 +`codewhale` は自己完結型の Rust リリースバイナリのペアとしてインストールされます。`codewhale` はディスパッチャーで、同じ場所にある `codewhale-tui` ランタイムを起動して対話セッションを実行します。npm、Homebrew、Docker は両方を自動でインストールします。Cargo や手動インストールでは、両方を同じディレクトリ(通常は `PATH` 上のディレクトリ)に置いてください。実行に Node.js や Python のランタイムは不要です。 ```bash # 1. npm — すでに Node を使っているなら最も簡単。npm パッケージは -# GitHub Releases から対応するビルド済みバイナリをダウンロードする +# GitHub Releases から対応するビルド済みバイナリペアをダウンロードする # 薄いインストーラーであり、codewhale 本体に Node ランタイム依存を加えるものではありません。 npm install -g codewhale -# 2. Cargo — Node 不要。 +# 2. Cargo — Node 不要。2 つの crate を両方インストールします。 cargo install codewhale-cli --locked # `codewhale` (エントリーポイント) cargo install codewhale-tui --locked # `codewhale-tui` (TUI バイナリ) # 3. Homebrew — macOS パッケージマネージャ。 +# tap/formula 名は旧名のままですが、codewhale と codewhale-tui をインストールします。 brew tap Hmbown/deepseek-tui brew install deepseek-tui -# 4. 直接ダウンロード — Node もツールチェーンも不要。 +# 4. 直接ダウンロード — GitHub Releases のプラットフォームアーカイブ。 # https://github.com/Hmbown/CodeWhale/releases -# Linux x64/ARM64、macOS x64/ARM64、Windows x64 向けのビルド済みバイナリがあります。 +# アーカイブには codewhale と codewhale-tui とインストールスクリプトが含まれます。 +# 個別バイナリもスクリプト用に添付されています。手動ではペアを同じ場所に置いてください。 # 5. Docker — ビルド済みリリースイメージ。 docker volume create codewhale-home @@ -72,73 +74,9 @@ CodeWhale はそのハーネスであり、DeepSeek V4 を中心に構築され | **明確な管轄権** | 9階層の権威を持つ成文憲法。ユーザーの意図が古い指示より優先。検証が自信より優先。 | | **再帰的改善** | V4 がハーネスの一部を書いた。ハーネスが改善されると V4 はより効果的になり、さらにハーネスを改善する。毎ターンがより強くなる。 | -オープンソース、単一バイナリ完結、ターミナルのために構築。 +オープンソース、ターミナルネイティブ、`codewhale` / `codewhale-tui` の Rust バイナリペアとして提供されています。 - -## インストール - -`codewhale` は自己完結型の Rust バイナリとして提供されており、**実行に Node.js や Python のランタイムは必要ありません。** すでにマシンにインストールされているものを選んでください。いずれの方法でも同じバイナリが `PATH` に配置されます。 - -```bash -# 1. npm — すでに Node を使っているなら最も簡単。npm パッケージは -# GitHub Releases から対応するビルド済みバイナリをダウンロードする -# 薄いインストーラーであり、codewhale 本体に Node ランタイム依存を加えるものではありません。 -npm install -g codewhale - -# 2. Cargo — Node 不要。 -cargo install codewhale-cli --locked # `codewhale` (エントリーポイント) -cargo install codewhale-tui --locked # `codewhale-tui` (TUI バイナリ) - -# 3. Homebrew — macOS パッケージマネージャ。 -brew tap Hmbown/deepseek-tui -brew install deepseek-tui - -# 4. 直接ダウンロード — Node もツールチェーンも不要。 -# https://github.com/Hmbown/CodeWhale/releases -# Linux x64/ARM64、macOS x64/ARM64、Windows x64 向けのビルド済みバイナリがあります。 - -# 5. Docker — ビルド済みリリースイメージ。 -docker volume create codewhale-home -docker run --rm -it \ - -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ - -v codewhale-home:/home/codewhale/.deepseek \ - -v "$PWD:/workspace" \ - -w /workspace \ - ghcr.io/hmbown/codewhale:latest -``` - -> 中国本土では、`--registry=https://registry.npmmirror.com` を指定して npm 経由のダウンロードを高速化するか、下記の[Cargo ミラー](#中国--ミラーフレンドリーなインストール)を利用してください。 - -既にインストール済みの場合は、インストール方法に合わせて更新してください: - -```bash -codewhale update -npm install -g codewhale@latest -brew update && brew upgrade deepseek-tui -cargo install codewhale-cli --locked --force -cargo install codewhale-tui --locked --force -``` - -[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml) -[![npm](https://img.shields.io/npm/v/codewhale)](https://www.npmjs.com/package/codewhale) -[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli) -[![DeepWiki](https://img.shields.io/badge/DeepWiki-Ask_AI-_.svg?style=flat&color=0052D9&labelColor=000000&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](https://deepwiki.com/Hmbown/CodeWhale) - -Buy me a coffee - -![codewhale スクリーンショット](assets/screenshot.png) - ---- - -## codewhale とは? - -モデルは質問に答えます。エージェントはタスクを完了します。その差がハーネス——モデルを取り巻くルール、ツール、証拠、フィードバックループという動作環境です。 - -CodeWhale はそのハーネスであり、DeepSeek V4 Pro と Flash のために構築されました。メンテナがモデルがタスクの途中で方向を見失ったり、ユーザーの現在の要求より古い指示に従ったり、コマンドが失敗すると諦めたりすることにうんざりしたことから、個人ツールとして始まりました。そこから生まれたのが、モデルの方向性を保つシステムです:憲法的なプロンプト階層、構造化された信頼境界、並列サブエージェント、プレフィックスキャッシュ対応のコンテキスト管理、そしてモデルが自己修正するための十分なシグナルを提供する検証の鼓動。 - -DeepSeek V4 はこのハーネスの一部を書くのを手伝いました。これは重要です——CodeWhale がすでに V4 を使う最も効果的な方法であり、V4 が改善するにつれてハーネスも改善することを意味します。各ターンがより良いプロンプト、より良いルール、より良いハンドオフを残します。次のターンはより強い位置から始まります。 - -### ハーネスの仕組み +## ハーネスの仕組み エージェントモデルは大規模な相反する情報を扱います:ユーザーの意図、プロジェクトルール、システムデフォルト、ツール出力、古いメモリが単一ターンで権威を競い合います。LLM が裁判官として機能するには管轄権が必要です——衝突したとき、どの情報源が勝つのか? @@ -178,7 +116,7 @@ codewhale --version codewhale --model auto ``` -ビルド済みバイナリは **Linux x64**、**Linux ARM64**(v0.8.8 以降)、**macOS x64**、**macOS ARM64**、**Windows x64** 向けに公開されています。その他のターゲット(musl、riscv64、FreeBSD など)は [ソースからのインストール](#install-from-source) または [docs/INSTALL.md](docs/INSTALL.md) を参照してください。 +ビルド済みバイナリペアとプラットフォームアーカイブは **Linux x64**、**Linux ARM64**(v0.8.8 以降)、**macOS x64**、**macOS ARM64**、**Windows x64** 向けに公開されています。その他のターゲット(musl、riscv64、FreeBSD など)は [ソースからのインストール](#install-from-source) または [docs/INSTALL.md](docs/INSTALL.md) を参照してください。 初回起動時に [DeepSeek API キー](https://platform.deepseek.com/api_keys) の入力を求められます。キーは `~/.deepseek/config.toml` に保存されるため、OS のクレデンシャルプロンプトなしに任意のディレクトリから利用できます。 @@ -224,10 +162,15 @@ codewhale --version ### Windows(Scoop) -[Scoop](https://scoop.sh) は Windows のパッケージマネージャです。インストール後、次を実行してください: +[Scoop](https://scoop.sh) は Windows のパッケージマネージャです。`codewhale` +パッケージは Scoop main bucket にありますが、manifest は GitHub/npm/Cargo +リリースより遅れることがあります。先に更新し、インストール後に +`codewhale --version` で確認してください: ```bash -scoop install deepseek-tui +scoop update +scoop install codewhale +codewhale --version ``` diff --git a/README.md b/README.md index f8c9c5c4..334ae74f 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,12 @@ ## Install -`codewhale` is distributed as Rust binaries: the dispatcher command -(`codewhale`) and the companion TUI runtime (`codewhale-tui`). Pick whichever -install path you already use; they all put the same commands on your `PATH`. -The npm package is an installer/wrapper for the release binaries, not the -agent runtime itself. +`codewhale` installs as a matched pair of self-contained Rust release binaries: +the `codewhale` dispatcher command and the sibling `codewhale-tui` runtime it +launches for interactive sessions. npm, Homebrew, and Docker install both for +you; Cargo and manual installs must put both binaries in the same directory +(normally a directory on your `PATH`). The npm package is only an +installer/wrapper for those release binaries; the agent does not run on Node. ```bash # 1. npm — easiest if you already use Node. The package downloads the @@ -25,12 +26,14 @@ cargo install codewhale-cli --locked # `codewhale` (entry point) cargo install codewhale-tui --locked # `codewhale-tui` (TUI binary) # 3. Homebrew — macOS package manager. +# The tap/formula name is legacy; it installs codewhale and codewhale-tui. brew tap Hmbown/deepseek-tui brew install deepseek-tui -# 4. Direct download — no package manager or toolchain. +# 4. Direct download — platform archive from GitHub Releases. # https://github.com/Hmbown/CodeWhale/releases -# Prebuilt for Linux x64/ARM64, macOS x64/ARM64, Windows x64. +# Archives include both codewhale and codewhale-tui plus an install script. +# Individual binaries are also attached for scripts; keep the pair together. # 5. Docker — prebuilt release image. docker volume create codewhale-home @@ -84,83 +87,10 @@ CodeWhale is that harness, built around DeepSeek V4 and guided by three ideas: | **Clear jurisdiction** | A written Constitution with nine tiers of authority. User intent outranks stale instructions. Verification outranks confidence. | | **Recursive improvement** | V4 helped write the harness. As the harness improves, V4 becomes more effective — and helps improve the harness further. Each turn starts stronger. | -It's open source, self-contained in a single binary, and built for the terminal. +It's open source, terminal-native, and packaged as a matched `codewhale` / +`codewhale-tui` Rust binary pair. - -## Install - -`codewhale` is distributed as Rust binaries: the dispatcher command -(`codewhale`) and the companion TUI runtime (`codewhale-tui`). Pick whichever -install path you already use; they all put the same commands on your `PATH`. -The npm package is an installer/wrapper for the release binaries, not the -agent runtime itself. - -```bash -# 1. npm — easiest if you already use Node. The package downloads the -# matching prebuilt Rust binaries from GitHub Releases. -npm install -g codewhale - -# 2. Cargo — no Node needed. Requires Rust 1.88+ (the crates use the -# 2024 edition; older toolchains fail with "feature `edition2024` is -# required"). Run `rustup update` first, or use a non-Cargo path below. -cargo install codewhale-cli --locked # `codewhale` (entry point) -cargo install codewhale-tui --locked # `codewhale-tui` (TUI binary) - -# 3. Homebrew — macOS package manager. -brew tap Hmbown/deepseek-tui -brew install deepseek-tui - -# 4. Direct download — no package manager or toolchain. -# https://github.com/Hmbown/CodeWhale/releases -# Prebuilt for Linux x64/ARM64, macOS x64/ARM64, Windows x64. - -# 5. Docker — prebuilt release image. -docker volume create codewhale-home -docker run --rm -it \ - -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ - -v codewhale-home:/home/codewhale/.deepseek \ - -v "$PWD:/workspace" \ - -w /workspace \ - ghcr.io/hmbown/codewhale:latest -``` - -> In mainland China, speed up the npm path with -> `--registry=https://registry.npmmirror.com`, or use the -> [Cargo mirror](#china--mirror-friendly-installation) below. -> -> Download safety: official release binaries live under -> `https://github.com/Hmbown/CodeWhale/releases`. For manual downloads, -> verify the SHA-256 manifest and avoid look-alike repositories or search-result -> mirrors. See [download safety and checksums](docs/INSTALL.md#2-download-safety-and-checksums). - -Already installed? Use the updater that matches the install path: - -```bash -codewhale update # release-binary updater -npm install -g codewhale@latest # npm wrapper -brew update && brew upgrade deepseek-tui -cargo install codewhale-cli --locked --force -cargo install codewhale-tui --locked --force -``` - -[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml) -[![npm](https://img.shields.io/npm/v/codewhale)](https://www.npmjs.com/package/codewhale) -[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli) -[DeepWiki project index](https://deepwiki.com/Hmbown/CodeWhale) - -![codewhale screenshot](assets/screenshot.png) - ---- - -## What Is It? - -A model answers a question. An agent finishes a task. The difference is the harness — the operating environment that surrounds the model with rules, tools, evidence, and feedback loops. - -CodeWhale is that harness, built around DeepSeek V4 Pro and Flash. It started as a personal tool because the maintainer got tired of models losing track mid-task, obeying stale instructions over the user's current request, or giving up when a command failed. What emerged was a system that keeps the model oriented: a constitutional prompt hierarchy, structured trust boundaries, parallel sub-agents, prefix-cache-aware context management, and verification beats that give the model enough signal to self-correct. - -DeepSeek V4 helped write parts of this harness. That matters because it means CodeWhale is already the most effective way to use V4 — and as V4 improves, the harness improves with it. Each turn leaves behind better prompts, better rules, and better handoffs. The next turn starts from a stronger position. - -### How the harness works +## How the Harness Works Agentic models deal with conflicting information at scale: user intent, project rules, system defaults, tool output, and stale memory all compete @@ -244,7 +174,7 @@ codewhale --version codewhale --model auto ``` -Prebuilt binaries are published for **Linux x64**, **Linux ARM64** (v0.8.8+), **macOS x64**, **macOS ARM64**, and **Windows x64**. For other targets (musl, riscv64, FreeBSD, etc.), see [Install from source](#install-from-source) or [docs/INSTALL.md](docs/INSTALL.md). +Prebuilt binary pairs and platform archives are published for **Linux x64**, **Linux ARM64** (v0.8.8+), **macOS x64**, **macOS ARM64**, and **Windows x64**. For other targets (musl, riscv64, FreeBSD, etc.), see [Install from source](#install-from-source) or [docs/INSTALL.md](docs/INSTALL.md). On first launch you'll be prompted for your [DeepSeek API key](https://platform.deepseek.com/api_keys). The key is saved to `~/.deepseek/config.toml` so it works from any directory without OS credential prompts. @@ -330,7 +260,7 @@ version with `codewhale --version`: ```bash scoop update -scoop install deepseek-tui +scoop install codewhale codewhale --version ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index 080c529e..314b5955 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,32 +1,36 @@ # CodeWhale -> **面向 [DeepSeek V4](https://platform.deepseek.com) 的终端原生编程智能体:100 万 token 上下文、思考模式流式推理、前缀缓存感知。自包含 Rust 二进制发布——开箱即带 MCP 客户端、沙箱和持久化任务队列。** +> **面向 [DeepSeek V4](https://platform.deepseek.com) 的终端原生编程智能体:100 万 token 上下文、思考模式流式推理、前缀缓存感知。以 `codewhale` 调度器和 `codewhale-tui` 运行时这一组自包含 Rust 二进制发布——开箱即带 MCP 客户端、沙箱和持久化任务队列。** [English README](README.md) [日本語 README](README.ja-JP.md) ## 安装 -`codewhale` 是自包含 Rust 二进制——**运行时不依赖 Node.js 或 Python**。 -下面几种方式装出来的是同一套二进制,按你已有的工具链选一个即可: +`codewhale` 以一组自包含 Rust 发布二进制安装:`codewhale` 调度器命令, +以及它在交互会话中启动的同级 `codewhale-tui` 运行时。npm、Homebrew 和 +Docker 会自动安装这两个二进制;Cargo 或手动下载时必须把两者放在同一目录 +(通常是 `PATH` 上的某个目录)。运行时不依赖 Node.js 或 Python。 ```bash # 1. npm —— 已装 Node 的最方便方式。npm 包只是一个下载器, -# 会从 GitHub Releases 拉取对应平台的预编译二进制, +# 会从 GitHub Releases 拉取对应平台的预编译二进制对, # 并不会让 codewhale 本身依赖 Node 运行时。 npm install -g codewhale -# 2. Cargo —— 无需 Node。 +# 2. Cargo —— 无需 Node,两个 crate 都要安装。 cargo install codewhale-cli --locked # `codewhale` 入口 cargo install codewhale-tui --locked # `codewhale-tui` TUI 二进制 # 3. Homebrew —— macOS 包管理器。 +# tap/formula 名称仍是旧名;实际安装 codewhale 和 codewhale-tui。 brew tap Hmbown/deepseek-tui brew install deepseek-tui -# 4. 直接下载 —— 无需任何工具链。 +# 4. 直接下载 —— GitHub Releases 的平台压缩包。 # https://github.com/Hmbown/CodeWhale/releases -# 覆盖 Linux x64/ARM64、macOS x64/ARM64、Windows x64 +# 压缩包包含 codewhale 和 codewhale-tui 以及安装脚本; +# 也提供单独二进制给脚本使用,手动安装时请把这一对放在一起。 # 5. Docker —— 预构建发布镜像。 docker volume create codewhale-home @@ -77,78 +81,9 @@ CodeWhale 就是这套框架,围绕 DeepSeek V4 构建,基于三个理念: | **清晰的管辖权** | 成文宪法,九层权威。用户意图优先于陈旧指令。验证优先于自信。 | | **递归改进** | V4 参与了框架的编写。框架改进 → V4 更高效 → 进一步改进框架。每轮从更强的位置开始。 | -开源、单二进制自包含、为终端构建。 +开源、终端原生,并以 `codewhale` / `codewhale-tui` 这一组 Rust 二进制发布。 - -## 安装 - -`codewhale` 是自包含 Rust 二进制——**运行时不依赖 Node.js 或 Python**。 -下面几种方式装出来的是同一套二进制,按你已有的工具链选一个即可: - -```bash -# 1. npm —— 已装 Node 的最方便方式。npm 包只是一个下载器, -# 会从 GitHub Releases 拉取对应平台的预编译二进制, -# 并不会让 codewhale 本身依赖 Node 运行时。 -npm install -g codewhale - -# 2. Cargo —— 无需 Node。 -cargo install codewhale-cli --locked # `codewhale` 入口 -cargo install codewhale-tui --locked # `codewhale-tui` TUI 二进制 - -# 3. Homebrew —— macOS 包管理器。 -brew tap Hmbown/deepseek-tui -brew install deepseek-tui - -# 4. 直接下载 —— 无需任何工具链。 -# https://github.com/Hmbown/CodeWhale/releases -# 覆盖 Linux x64/ARM64、macOS x64/ARM64、Windows x64 - -# 5. Docker —— 预构建发布镜像。 -docker volume create codewhale-home -docker run --rm -it \ - -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ - -v codewhale-home:/home/codewhale/.deepseek \ - -v "$PWD:/workspace" \ - -w /workspace \ - ghcr.io/hmbown/codewhale:latest -``` - -> 中国大陆访问较慢时,npm 可加 `--registry=https://registry.npmmirror.com`, -> 或使用下方的 [Cargo 镜像](#中国大陆--镜像友好安装)。 -> -> 下载安全:官方二进制只发布在 -> `https://github.com/Hmbown/CodeWhale/releases`。手动下载时请校验 -> SHA-256 manifest,并避免相似仓库名或搜索结果里的镜像站。详见 -> [下载安全与校验](docs/INSTALL.md#2-download-safety-and-checksums)。 - -已经安装过?按你的安装方式更新: - -```bash -codewhale update # release 二进制更新器 -npm install -g codewhale@latest # npm 包装器 -brew update && brew upgrade deepseek-tui -cargo install codewhale-cli --locked --force -cargo install codewhale-tui --locked --force -``` - -[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml) -[![npm](https://img.shields.io/npm/v/codewhale)](https://www.npmjs.com/package/codewhale) -[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli) -[DeepWiki project index](https://deepwiki.com/Hmbown/CodeWhale) - -![codewhale 截图](assets/screenshot.png) - ---- - -## 这是什么? - -模型回答问题。智能体完成任务。区别在于运行框架——包围模型的规则、工具、证据和反馈循环。 - -CodeWhale 就是这套框架,围绕 DeepSeek V4 Pro 和 Flash 构建。它最初是一个个人工具,因为维护者受够了模型在任务中途迷失方向、服从过时指令而非用户当前请求、或者命令失败就放弃。结果诞生了一个让模型保持方向的系统:宪政提示层级、结构化信任边界、并行子智能体、前缀缓存感知的上下文管理、以及让模型有足够信号来自我校正的验证节拍。 - -DeepSeek V4 参与了这套框架的部分编写。这很重要——它意味着 CodeWhale 已经是使用 V4 最有效的方式,并且随着 V4 的改进,框架也会随之改进。每一轮都留下更好的提示、更好的规则、更好的交接。下一轮从一个更强的位置开始。 - -### 框架如何工作 +## 框架如何工作 智能体模型面临大规模的冲突信息:用户意图、项目规则、系统默认值、工具输出和陈旧记忆在单轮对话中争夺权威。LLM 作为裁判需要管辖权——当它们冲突时,哪个来源胜出? @@ -199,7 +134,7 @@ codewhale --version codewhale --model auto ``` -预构建二进制覆盖 **Linux x64**、**Linux ARM64**(v0.8.8 起)、**macOS x64**、**macOS ARM64** 和 **Windows x64**。其他目标平台(musl、riscv64、FreeBSD 等)请见下方的[从源码安装](#从源码安装)或 [docs/INSTALL.md](docs/INSTALL.md)。 +预构建二进制对和平台压缩包覆盖 **Linux x64**、**Linux ARM64**(v0.8.8 起)、**macOS x64**、**macOS ARM64** 和 **Windows x64**。其他目标平台(musl、riscv64、FreeBSD 等)请见下方的[从源码安装](#从源码安装)或 [docs/INSTALL.md](docs/INSTALL.md)。 首次启动时会提示输入 [DeepSeek API key](https://platform.deepseek.com/api_keys)。密钥保存到 `~/.deepseek/config.toml`,在任意目录、IDE 终端和脚本中都能使用,不会触发系统密钥环弹窗。 @@ -277,7 +212,7 @@ release。先运行 `scoop update`,安装后用 `codewhale --version` 核对 ```bash scoop update -scoop install deepseek-tui +scoop install codewhale codewhale --version ``` diff --git a/docs/INSTALL.md b/docs/INSTALL.md index ab4ef018..473618b5 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -12,8 +12,8 @@ If you just want the short version, see the ## 1. Supported platforms -`codewhale-tui` ships prebuilt binaries for these -platform/architecture combinations from v0.8.8 onward: +CodeWhale ships matched `codewhale` and `codewhale-tui` prebuilt binaries for +these platform/architecture combinations from v0.8.8 onward: | Platform | Architecture | npm install | `cargo install` | GitHub release asset | | ------------ | ------------ | :---------: | :-------------: | ----------------------------------------------------- | @@ -49,7 +49,7 @@ systems such as Alpine should use [Build from source](#7-build-from-source). Official release binaries are published only from `https://github.com/Hmbown/CodeWhale/releases` and the npm package named -`codewhale-tui`. Do not install release assets from look-alike repositories, +`codewhale`. Do not install release assets from look-alike repositories, archives, or search-result mirrors unless you deliberately trust that mirror. Every GitHub release includes `codewhale-artifacts-sha256.txt`. If you download diff --git a/web/lib/facts.generated.ts b/web/lib/facts.generated.ts index 642acbfe..d7cbe216 100644 --- a/web/lib/facts.generated.ts +++ b/web/lib/facts.generated.ts @@ -18,8 +18,8 @@ export interface RepoFacts { } export const FACTS: RepoFacts = { - "generatedAt": "2026-05-26T15:45:43.239Z", - "version": "0.8.45", + "generatedAt": "2026-05-26T17:03:21.939Z", + "version": "0.8.46", "crates": [ "agent", "app-server", diff --git a/web/redirect/src/index.ts b/web/redirect/src/index.ts new file mode 100644 index 00000000..07f803ab --- /dev/null +++ b/web/redirect/src/index.ts @@ -0,0 +1,7 @@ +export default { + fetch(request: Request): Response { + const url = new URL(request.url); + url.host = "codewhale.net"; + return Response.redirect(url.toString(), 301); + }, +}; diff --git a/web/redirect/wrangler.jsonc b/web/redirect/wrangler.jsonc new file mode 100644 index 00000000..6d910310 --- /dev/null +++ b/web/redirect/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "../node_modules/wrangler/config-schema.json", + "name": "deepseek-tui-web", + "main": "src/index.ts", + "compatibility_date": "2025-04-01", + "observability": { "enabled": true }, + "routes": [ + { "pattern": "deepseek-tui.com", "custom_domain": true }, + { "pattern": "www.deepseek-tui.com", "custom_domain": true } + ] +} diff --git a/web/wrangler.jsonc b/web/wrangler.jsonc index 6014a3a8..a4073b9d 100644 --- a/web/wrangler.jsonc +++ b/web/wrangler.jsonc @@ -1,6 +1,6 @@ { "$schema": "node_modules/wrangler/config-schema.json", - "name": "deepseek-tui-web", + "name": "codewhale-web", "main": "worker.ts", "compatibility_date": "2025-04-01", "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], @@ -9,6 +9,10 @@ "binding": "ASSETS" }, "observability": { "enabled": true }, + "routes": [ + { "pattern": "codewhale.net", "custom_domain": true }, + { "pattern": "www.codewhale.net", "custom_domain": true } + ], "kv_namespaces": [ { "binding": "CURATED_KV", @@ -22,7 +26,7 @@ "vars": { "GITHUB_REPO": "Hmbown/CodeWhale", "DEEPSEEK_MODEL": "deepseek-v4-flash", - "DEEPSEEK_BASE_URL": "https://gateway.ai.cloudflare.com/v1/cf50f793171d7cb3b2ce23368b69cdcb/deepseek-tui-web/deepseek" + "DEEPSEEK_BASE_URL": "https://gateway.ai.cloudflare.com/v1/cf50f793171d7cb3b2ce23368b69cdcb/codewhale-web/deepseek" }, "triggers": { "crons": [ From e5e34d406309ed74b0a3915c583a416d5153fbbd Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 12:13:30 -0500 Subject: [PATCH 078/283] =?UTF-8?q?fix:=20resolve=20clippy=20warnings=20fo?= =?UTF-8?q?r=20Rust=201.95=20=E2=80=94=20vec=5Finit=5Fthen=5Fpush,=20unnec?= =?UTF-8?q?essary=5Fmap=5For?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tui/src/sandbox/mod.rs | 2 +- crates/tui/src/sandbox/seccomp.rs | 65 +++++++++++++++---------------- 2 files changed, 32 insertions(+), 35 deletions(-) diff --git a/crates/tui/src/sandbox/mod.rs b/crates/tui/src/sandbox/mod.rs index da0298c3..85a6898c 100644 --- a/crates/tui/src/sandbox/mod.rs +++ b/crates/tui/src/sandbox/mod.rs @@ -824,7 +824,7 @@ mod tests { #[cfg(target_os = "linux")] { let marker = env.env.get("DEEPSEEK_SANDBOX"); - assert!(marker.map_or(true, |v| v != "bwrap")); + assert!(marker.is_none_or(|v| v != "bwrap")); } let _ = env; } diff --git a/crates/tui/src/sandbox/seccomp.rs b/crates/tui/src/sandbox/seccomp.rs index ff0637cf..b384ed8c 100644 --- a/crates/tui/src/sandbox/seccomp.rs +++ b/crates/tui/src/sandbox/seccomp.rs @@ -276,40 +276,37 @@ pub fn apply_seccomp_filter() -> std::io::Result<()> { ]; // Build the BPF program. - let mut filter = Vec::::new(); - - // Instruction 0: load architecture from seccomp_data.arch - filter.push(sock_filter { - code: BPF_LD | BPF_W | BPF_ABS, - jt: 0, - jf: 0, - k: 4, // offset of arch in seccomp_data - }); - - // Instruction 1: compare with AUDIT_ARCH_X86_64 - // If match, jump to next instruction; if not, kill process - filter.push(sock_filter { - code: BPF_JMP | BPF_JEQ, - jt: 0, - jf: 1, // jump 1 forward (to KILL) if arch doesn't match - k: AUDIT_ARCH_X86_64, - }); - - // Instruction 2: KILL (wrong architecture) - filter.push(sock_filter { - code: BPF_RET, - jt: 0, - jf: 0, - k: SECCOMP_RET_KILL_PROCESS, - }); - - // Instruction 3: load syscall number from seccomp_data.nr - filter.push(sock_filter { - code: BPF_LD | BPF_W | BPF_ABS, - jt: 0, - jf: 0, - k: 0, // offset of nr in seccomp_data - }); + let mut filter = vec![ + // Instruction 0: load architecture from seccomp_data.arch + sock_filter { + code: BPF_LD | BPF_W | BPF_ABS, + jt: 0, + jf: 0, + k: 4, // offset of arch in seccomp_data + }, + // Instruction 1: compare with AUDIT_ARCH_X86_64 + // If match, jump to next instruction; if not, kill process + sock_filter { + code: BPF_JMP | BPF_JEQ, + jt: 0, + jf: 1, // jump 1 forward (to KILL) if arch doesn't match + k: AUDIT_ARCH_X86_64, + }, + // Instruction 2: KILL (wrong architecture) + sock_filter { + code: BPF_RET, + jt: 0, + jf: 0, + k: SECCOMP_RET_KILL_PROCESS, + }, + // Instruction 3: load syscall number from seccomp_data.nr + sock_filter { + code: BPF_LD | BPF_W | BPF_ABS, + jt: 0, + jf: 0, + k: 0, // offset of nr in seccomp_data + }, + ]; // For each allowed syscall, add a compare+jump to ALLOW. // We use a linear scan for simplicity: each JEQ instruction jumps From aa83446d6b9f62384bdcc10ebcafe4b85a4bc590 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 12:23:49 -0500 Subject: [PATCH 079/283] fix(tests): use cat instead of true in wlcopy test to avoid EPIPE The wlcopy_helper_succeeds_when_binary_returns_zero test used `true` as a stand-in for wl-copy, but `true` exits immediately without reading stdin. On Linux CI runners the process exits before the parent can write to the pipe, producing EPIPE and failing the is_ok() assertion. Switch to `cat` which reads stdin until EOF (when the pipe is closed) and then exits 0, faithfully modelling a successful wl-copy invocation. --- crates/tui/src/tui/clipboard.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/tui/clipboard.rs b/crates/tui/src/tui/clipboard.rs index 2eadfd0f..dffadfac 100644 --- a/crates/tui/src/tui/clipboard.rs +++ b/crates/tui/src/tui/clipboard.rs @@ -442,7 +442,12 @@ mod tests { #[cfg(target_os = "linux")] #[test] fn wlcopy_helper_succeeds_when_binary_returns_zero() { - let result = write_text_with_wlcopy_using_argv("true", "test"); + // Use `cat` instead of `true` because `true` exits immediately + // without reading stdin, causing EPIPE before we can check the + // exit status. `cat` consumes stdin until EOF (when we drop the + // pipe) and then exits 0, faithfully modelling a successful + // wl-copy invocation. + let result = write_text_with_wlcopy_using_argv("cat", "test"); assert!(result.is_ok()); } From a06bbe57a6def8e7d80b5c95d642dfe8fa48ec7e Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 13:09:48 -0500 Subject: [PATCH 080/283] fix(tools): replace cross-await RwLock with Semaphore to prevent deadlock Replace `Arc>` in ToolCallRuntime with `Arc` to eliminate the risk of a tool re-entering and deadlocking on the same lock. Parallel tools now acquire then immediately drop the permit, allowing concurrent execution after any in-flight serial tool finishes. Serial tools hold the permit for the full duration. Fixes #2157. Harvested from #1856. Co-authored-by: Fire-dtx <58944505+Fire-dtx@users.noreply.github.com> --- crates/tools/src/lib.rs | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index a7179410..050b840f 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -8,7 +8,7 @@ use async_trait::async_trait; use codewhale_protocol::{ToolKind, ToolOutput, ToolPayload}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use tokio::sync::RwLock; +use tokio::sync::Semaphore; /// Capabilities that a tool may have or require. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -309,9 +309,19 @@ pub trait ToolHandler: Send + Sync { ) -> std::result::Result; } -#[derive(Debug, Default)] +#[derive(Debug)] pub struct ToolCallRuntime { - pub parallel_execution: Arc>, + /// Serialise non-parallel tool executions. Capacity 1 ensures at most one + /// serial tool runs at a time, and blocks parallel tools while it runs. + serial_semaphore: Arc, +} + +impl Default for ToolCallRuntime { + fn default() -> Self { + Self { + serial_semaphore: Arc::new(Semaphore::new(1)), + } + } } #[derive(Default)] @@ -380,13 +390,20 @@ impl ToolRegistry { }; if configured.supports_parallel_tool_calls { - let _guard = self.runtime.parallel_execution.read().await; + // Parallel tools wait for any in-flight serial tool to finish, + // but do not hold the permit so other parallel tools may run concurrently. + drop(self.runtime.serial_semaphore.acquire().await + .map_err(|_| FunctionCallError::Cancelled { name: call.name })?); self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation) .await } else { - let _guard = self.runtime.parallel_execution.write().await; + // Serial tools hold the semaphore for the full execution duration, + // preventing other serial AND parallel tools from starting. + let _permit = self.runtime.serial_semaphore.acquire().await + .map_err(|_| FunctionCallError::Cancelled { name: call.name })?; self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation) .await + // _permit dropped here, releasing the semaphore. } } From 84463711b493ab6f22de241c0480a2b49568f0e2 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 13:14:55 -0500 Subject: [PATCH 081/283] feat(composer): mouse + keyboard text selection with copy/cut Add mouse drag selection and Shift+Arrow text selection in the composer input box. Ctrl+C copies selected text; Ctrl+X cuts (or toggles mode if no selection). Selection highlighting uses the theme's selection_bg color. Mouse coordinate mapping accounts for wrapping, scroll offset, and padding. Also fix Home, End, Ctrl+A, and Ctrl+E to clear the selection anchor before jumping, matching the existing Left/Right behavior. Without these calls a stale anchor silently reforms a selection and can cause unintended deletions on the next keystroke. Harvested from #2228. Co-authored-by: imkingjh999 --- CHANGELOG.md | 13 ++ crates/tui/src/tui/app.rs | 203 +++++++++++++++++++++++++++++- crates/tui/src/tui/mouse_ui.rs | 107 ++++++++++++++++ crates/tui/src/tui/ui.rs | 95 ++++++++++++-- crates/tui/src/tui/widgets/mod.rs | 169 ++++++++++++++++++++++++- 5 files changed, 575 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c266c89..007fc825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Composer text selection with copy/cut.** Mouse drag and Shift+Arrow + selection in the composer input box, with Ctrl+C copy and Ctrl+X cut + support. Home, End, Ctrl+A, and Ctrl+E now clear the selection to prevent + accidental deletions on the next keystroke (#2228). + +### Fixed + +- **Deadlock when spawning multiple concurrent sub-agents.** Replaced + `RwLock`-based serialisation with a `Semaphore(1)` in `ToolCallRuntime`, + preventing re-entrant tool calls from deadlocking on the same lock (#1856). + ## [0.8.46] - 2026-05-26 ### Added diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 2c3ec0c9..201890ad 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -368,7 +368,7 @@ pub(crate) struct InputHistoryDraft { cursor: usize, } -fn char_count(text: &str) -> usize { +pub(crate) fn char_count(text: &str) -> usize { text.chars().count() } @@ -902,6 +902,10 @@ pub struct ComposerState { /// user presses `d` in Normal mode; cleared on the next key (either `d` /// to complete `dd`, or any other key to cancel). pub vim_pending_d: bool, + /// When set, the cursor is the active end of a text selection and + /// `selection_anchor` is the fixed end. Both are char-indexed. + /// `None` means no selection is active. + pub selection_anchor: Option, } impl Default for ComposerState { @@ -926,6 +930,7 @@ impl Default for ComposerState { vim_enabled: false, vim_mode: VimMode::Normal, vim_pending_d: false, + selection_anchor: None, } } } @@ -940,11 +945,21 @@ pub struct ViewportState { pub selection_autoscroll: Option, pub transcript_scrollbar_dragging: bool, pub last_transcript_area: Option, + pub last_composer_area: Option, pub last_transcript_top: usize, pub last_transcript_visible: usize, pub last_transcript_total: usize, pub last_transcript_padding_top: usize, pub jump_to_latest_button_area: Option, + /// Inner content rect of the composer (excluding border/padding), + /// stored at render time for mouse coordinate mapping. + pub last_composer_content: Option, + /// Number of rendered text lines scrolled off the top of the composer, + /// stored at render time for mouse coordinate mapping. + pub last_composer_scroll_offset: usize, + /// Vertical padding above the first text line in the composer, + /// stored at render time for mouse coordinate mapping. + pub last_composer_top_padding: usize, } impl Default for ViewportState { @@ -958,11 +973,15 @@ impl Default for ViewportState { selection_autoscroll: None, transcript_scrollbar_dragging: false, last_transcript_area: None, + last_composer_area: None, last_transcript_top: 0, last_transcript_visible: 0, last_transcript_total: 0, last_transcript_padding_top: 0, jump_to_latest_button_area: None, + last_composer_content: None, + last_composer_scroll_offset: 0, + last_composer_top_padding: 0, } } } @@ -1809,6 +1828,7 @@ impl App { vim_enabled: composer_vim_enabled, vim_mode: VimMode::Normal, vim_pending_d: false, + selection_anchor: None, }, viewport: ViewportState::default(), goal: GoalState::default(), @@ -3124,6 +3144,7 @@ impl App { if text.is_empty() { return; } + self.delete_selection(); self.selected_attachment_index = None; let cursor = self.cursor_position.min(char_count(&self.input)); let byte_index = byte_index_at_char(&self.input, cursor); @@ -3383,6 +3404,7 @@ impl App { pub fn insert_char(&mut self, c: char) { self.clear_input_history_navigation(); + self.delete_selection(); self.selected_attachment_index = None; let cursor = self.cursor_position.min(char_count(&self.input)); let byte_index = byte_index_at_char(&self.input, cursor); @@ -3409,6 +3431,9 @@ impl App { pub fn delete_char(&mut self) { self.clear_input_history_navigation(); + if self.delete_selection() { + return; + } self.selected_attachment_index = None; if self.cursor_position == 0 { return; @@ -3426,6 +3451,9 @@ impl App { pub fn delete_char_forward(&mut self) { self.clear_input_history_navigation(); + if self.delete_selection() { + return; + } self.selected_attachment_index = None; if self.input.is_empty() { return; @@ -3444,6 +3472,9 @@ impl App { /// Delete the word before the cursor. pub fn delete_word_backward(&mut self) { self.clear_input_history_navigation(); + if self.delete_selection() { + return; + } self.selected_attachment_index = None; if self.cursor_position == 0 { return; @@ -3485,6 +3516,9 @@ impl App { /// Delete from the cursor to the start of the line. pub fn delete_to_start_of_line(&mut self) { self.clear_input_history_navigation(); + if self.delete_selection() { + return; + } self.selected_attachment_index = None; if self.cursor_position == 0 { return; @@ -3510,6 +3544,9 @@ impl App { /// Delete the word after the cursor. pub fn delete_word_forward(&mut self) { self.clear_input_history_navigation(); + if self.delete_selection() { + return; + } self.selected_attachment_index = None; let cursor_byte = byte_index_at_char(&self.input, self.cursor_position); if cursor_byte >= self.input.len() { @@ -3554,6 +3591,13 @@ impl App { /// Returns `true` when bytes were moved into the kill buffer. pub fn kill_to_end_of_line(&mut self) -> bool { self.clear_input_history_navigation(); + if let Some((start, end)) = self.selection_range() { + let sb = byte_index_at_char(&self.input, start); + let eb = byte_index_at_char(&self.input, end); + self.kill_buffer = self.input[sb..eb].to_string(); + self.delete_selection(); + return true; + } let total_chars = char_count(&self.input); let cursor = self.cursor_position.min(total_chars); let start_byte = byte_index_at_char(&self.input, cursor); @@ -3599,6 +3643,7 @@ impl App { if self.kill_buffer.is_empty() { return false; } + self.delete_selection(); self.clear_input_history_navigation(); let text = self.kill_buffer.clone(); let cursor = self.cursor_position.min(char_count(&self.input)); @@ -3724,6 +3769,58 @@ impl App { self.needs_redraw = true; } + // === Selection helpers === + + /// Return the (start, end) of the active selection, or `None`. + /// `start` is inclusive, `end` is exclusive; both are char indices. + pub fn selection_range(&self) -> Option<(usize, usize)> { + let anchor = self.selection_anchor?; + let cursor = self.cursor_position; + if anchor == cursor { + return None; + } + Some(if anchor < cursor { + (anchor, cursor) + } else { + (cursor, anchor) + }) + } + + /// Return the selected text, or empty string if no selection. + pub fn selected_text(&self) -> String { + self.selection_range() + .map(|(s, e)| { + let sb = byte_index_at_char(&self.input, s); + let eb = byte_index_at_char(&self.input, e); + self.input[sb..eb].to_string() + }) + .unwrap_or_default() + } + + /// Delete the selected text, place cursor at the start of the deleted range. + /// Returns true if a selection was deleted. + pub fn delete_selection(&mut self) -> bool { + let Some((start, end)) = self.selection_range() else { + return false; + }; + let sb = byte_index_at_char(&self.input, start); + let eb = byte_index_at_char(&self.input, end); + self.input.replace_range(sb..eb, ""); + self.cursor_position = start; + self.selection_anchor = None; + self.clear_input_history_navigation(); + self.slash_menu_hidden = false; + self.mention_menu_hidden = false; + self.mention_menu_selected = 0; + self.needs_redraw = true; + true + } + + /// Clear the selection without moving the cursor. + pub fn clear_selection(&mut self) { + self.selection_anchor = None; + } + // === Vim composer mode helpers === /// Move the cursor to the start of the current logical line (vim `0`). @@ -3906,6 +4003,7 @@ impl App { self.clear_input_history_navigation(); self.input.clear(); self.cursor_position = 0; + self.selection_anchor = None; self.selected_attachment_index = None; self.slash_menu_selected = 0; self.slash_menu_hidden = false; @@ -6662,4 +6760,107 @@ mod tests { assert_eq!(app.input, "café 你好"); assert_eq!(app.cursor_position, 7); } + + #[test] + fn selection_range_returns_none_when_no_anchor() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = None; + assert!(app.selection_range().is_none()); + } + + #[test] + fn selection_range_returns_ordered_range() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + assert_eq!(app.selection_range(), Some((2, 5))); + } + + #[test] + fn selection_range_normalizes_order() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 2; + app.selection_anchor = Some(5); + assert_eq!(app.selection_range(), Some((2, 5))); + } + + #[test] + fn selection_range_returns_none_when_anchor_equals_cursor() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello".to_string(); + app.cursor_position = 3; + app.selection_anchor = Some(3); + assert!(app.selection_range().is_none()); + } + + #[test] + fn delete_selection_removes_selected_text() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + assert!(app.delete_selection()); + assert_eq!(app.input, "he world"); + assert_eq!(app.cursor_position, 2); + assert!(app.selection_anchor.is_none()); + } + + #[test] + fn insert_char_replaces_selection() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + app.insert_char('X'); + assert_eq!(app.input, "heX world"); + assert_eq!(app.cursor_position, 3); + assert!(app.selection_anchor.is_none()); + } + + #[test] + fn delete_char_removes_selection_instead_of_single_char() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + app.delete_char(); + assert_eq!(app.input, "he world"); + assert_eq!(app.cursor_position, 2); + } + + #[test] + fn selected_text_returns_correct_substring() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + assert_eq!(app.selected_text(), "llo"); + } + + #[test] + fn insert_str_replaces_selection() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello world".to_string(); + app.cursor_position = 5; + app.selection_anchor = Some(2); + app.insert_str("yo"); + assert_eq!(app.input, "heyo world"); + assert_eq!(app.cursor_position, 4); + assert!(app.selection_anchor.is_none()); + } + + #[test] + fn delete_selection_noop_when_no_selection() { + let mut app = App::new(test_options(false), &Config::default()); + app.input = "hello".to_string(); + app.cursor_position = 3; + app.selection_anchor = None; + assert!(!app.delete_selection()); + assert_eq!(app.input, "hello"); + assert_eq!(app.cursor_position, 3); + } } diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index c3c985c1..47e323a7 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -2,6 +2,8 @@ use std::time::{Duration, Instant}; use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::Rect; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; use crate::tui::app::App; use crate::tui::command_palette::{ @@ -37,6 +39,91 @@ pub(crate) fn should_drop_loading_mouse_motion(app: &App, mouse: MouseEvent) -> } } +/// Map a mouse (column, row) within the composer area to a char index +/// in the composer input string. Uses the inner content rect (border-aware) +/// for coordinate mapping, and accounts for vertical padding and scroll offset. +fn mouse_pos_to_char_index(app: &App, col: u16, row: u16, inner: Rect) -> Option { + let rel_col = col.saturating_sub(inner.x) as usize; + let rel_row = row.saturating_sub(inner.y) as usize; + + if app.input.is_empty() { + return Some(0); + } + + let width = inner.width.max(1) as usize; + let wrapped = crate::tui::widgets::wrap_input_lines_for_mouse(&app.input, width); + + // Subtract the vertical top-padding (centering of short inputs). + let text_row = rel_row.saturating_sub(app.viewport.last_composer_top_padding); + + // Add the scroll offset (lines scrolled out of view). + let absolute_row = text_row + app.viewport.last_composer_scroll_offset; + + if absolute_row >= wrapped.len() { + return Some(app.input.chars().count()); + } + + let (line_start, line_text) = &wrapped[absolute_row]; + + let mut char_offset = 0usize; + let mut col_used = 0usize; + for g in line_text.graphemes(true) { + let gw = g.width(); + if col_used + gw > rel_col { + break; + } + col_used += gw; + char_offset += g.chars().count(); + } + Some(line_start + char_offset) +} + +/// Handle mouse events within the composer area. +/// Returns true if the event was consumed. +pub(crate) fn handle_composer_mouse(app: &mut App, mouse: MouseEvent) -> bool { + // Use outer area for hit-testing (includes border). + let Some(area) = app.viewport.last_composer_area else { + return false; + }; + if mouse.column < area.x + || mouse.column >= area.x + area.width + || mouse.row < area.y + || mouse.row >= area.y + area.height + { + return false; + } + // Use inner content rect for coordinate-to-char mapping (border-aware). + let inner = app.viewport.last_composer_content.unwrap_or(area); + + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + if let Some(pos) = mouse_pos_to_char_index(app, mouse.column, mouse.row, inner) { + app.cursor_position = pos; + app.selection_anchor = None; + app.needs_redraw = true; + } + true + } + MouseEventKind::Drag(MouseButton::Left) => { + if let Some(pos) = mouse_pos_to_char_index(app, mouse.column, mouse.row, inner) { + if app.selection_anchor.is_none() { + app.selection_anchor = Some(app.cursor_position); + } + app.cursor_position = pos; + app.needs_redraw = true; + } + true + } + MouseEventKind::Up(MouseButton::Left) => { + if app.selection_anchor == Some(app.cursor_position) { + app.selection_anchor = None; + } + true + } + _ => false, + } +} + pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec { if app.view_stack.top_kind() == Some(ModalKind::ContextMenu) { if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) { @@ -52,6 +139,11 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec { // Update last mouse position for tooltip rendering. @@ -585,6 +677,10 @@ pub(crate) fn selection_point_from_position( } pub(crate) fn selection_has_content(app: &App) -> bool { + // Composer selection takes priority (same as Cmd+C handler above). + if !app.selected_text().is_empty() { + return true; + } selection_to_text(app).is_some_and(|text| !text.is_empty()) } @@ -613,6 +709,17 @@ pub(crate) fn ctrl_c_disposition(app: &App) -> CtrlCDisposition { } pub(crate) fn copy_active_selection(app: &mut App) { + // Composer selection takes priority. + let sel = app.selected_text(); + if !sel.is_empty() { + if app.clipboard.write_text(&sel).is_ok() { + app.status_message = Some("Selection copied".to_string()); + } else { + app.status_message = Some("Copy failed".to_string()); + } + app.clear_selection(); + return; + } if !app.viewport.transcript_selection.is_active() { return; } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 2ba52ef9..7c77715d 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2969,7 +2969,17 @@ async fn run_event_loop( KeyCode::Char('c') | KeyCode::Char('C') if key_shortcuts::is_copy_shortcut(&key) => { - copy_active_selection(app); + let sel = app.selected_text(); + if !sel.is_empty() { + if app.clipboard.write_text(&sel).is_ok() { + app.push_status_toast("Copied to clipboard", StatusToastLevel::Info, None); + app.clear_selection(); + } else { + app.push_status_toast("Copy failed", StatusToastLevel::Error, None); + } + } else { + copy_active_selection(app); + } } KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { // Four behaviors layered on Ctrl+C in priority order — see @@ -3482,16 +3492,32 @@ async fn run_event_loop( app.delete_char_forward(); } KeyCode::Delete => {} + KeyCode::Left if key.modifiers.contains(KeyModifiers::SHIFT) => { + if app.selection_anchor.is_none() { + app.selection_anchor = Some(app.cursor_position); + } + app.move_cursor_left(); + } KeyCode::Left if is_word_cursor_modifier(key.modifiers) => { + app.clear_selection(); app.move_cursor_word_backward(); } KeyCode::Left => { + app.clear_selection(); app.move_cursor_left(); } + KeyCode::Right if key.modifiers.contains(KeyModifiers::SHIFT) => { + if app.selection_anchor.is_none() { + app.selection_anchor = Some(app.cursor_position); + } + app.move_cursor_right(); + } KeyCode::Right if is_word_cursor_modifier(key.modifiers) => { + app.clear_selection(); app.move_cursor_word_forward(); } KeyCode::Right => { + app.clear_selection(); app.move_cursor_right(); } KeyCode::Home if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -3507,15 +3533,19 @@ async fn run_event_loop( KeyCode::Home | KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.clear_selection(); app.move_cursor_start(); } KeyCode::Home => { + app.clear_selection(); app.move_cursor_line_start(); } KeyCode::End => { + app.clear_selection(); app.move_cursor_line_end(); } KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.clear_selection(); app.move_cursor_end(); } KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -3618,12 +3648,22 @@ async fn run_event_loop( } } KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => { - let new_mode = match app.mode { - AppMode::Plan => AppMode::Agent, - AppMode::Agent => AppMode::Yolo, - AppMode::Yolo => AppMode::Plan, - }; - app.set_mode(new_mode); + let sel = app.selected_text(); + if !sel.is_empty() { + if app.clipboard.write_text(&sel).is_ok() { + app.push_status_toast("Cut to clipboard", StatusToastLevel::Info, None); + app.delete_selection(); + } else { + app.push_status_toast("Cut failed", StatusToastLevel::Error, None); + } + } else { + let new_mode = match app.mode { + AppMode::Plan => AppMode::Agent, + AppMode::Agent => AppMode::Yolo, + AppMode::Yolo => AppMode::Plan, + }; + app.set_mode(new_mode); + } } _ if key_shortcuts::is_paste_shortcut(&key) => { app.paste_from_clipboard(); @@ -5826,6 +5866,47 @@ fn render(f: &mut Frame, app: &mut App) { composer_widget.render(chunks[3], buf); composer_widget.cursor_pos(chunks[3]) }; + app.viewport.last_composer_area = Some(chunks[3]); + { + let area = chunks[3]; + let has_panel = app.composer_border && area.height >= 3 && area.width >= 12; + let inner = if has_panel { + ratatui::widgets::Block::default() + .borders(ratatui::widgets::Borders::ALL) + .inner(area) + } else { + area + }; + app.viewport.last_composer_content = Some(inner); + + // Compute scroll offset and top padding for mouse coordinate mapping. + let input_text = app.composer_display_input(); + let input_cursor = app.composer_display_cursor(); + let content_width = usize::from(inner.width.max(1)); + let menu_lines = ComposerWidget::new( + app, + composer_max_height, + &slash_menu_entries, + &mention_menu_entries, + ) + .active_menu_reserved_rows(); + let budget = crate::tui::widgets::composer_input_rows_budget(inner.height, menu_lines); + let (_, _, _, scroll_offset) = crate::tui::widgets::layout_input_with_scroll( + input_text, + input_cursor, + content_width, + budget, + ); + let visible_lines = if input_text.is_empty() { + 1 + } else { + // Count wrapped lines (approximation matching the render path). + crate::tui::widgets::wrap_input_lines_for_mouse(input_text, content_width).len() + }; + let top_padding = budget.saturating_sub(visible_lines.clamp(1, budget)); + app.viewport.last_composer_scroll_offset = scroll_offset; + app.viewport.last_composer_top_padding = top_padding; + } if let Some(cursor_pos) = cursor_pos { f.set_cursor_position(cursor_pos); } diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index f1e395e6..d78c1a96 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -474,7 +474,7 @@ impl<'a> ComposerWidget<'a> { /// backend's per-cell write cost makes the layout jitter visible /// even though the work is tiny on Unix terminals. See user /// feedback in v0.8.8 polish thread. - fn active_menu_reserved_rows(&self) -> usize { + pub fn active_menu_reserved_rows(&self) -> usize { let actual = self.active_menu_row_count(); if actual == 0 { return 0; @@ -535,8 +535,8 @@ impl Renderable for ComposerWidget<'_> { let input_rows_budget = composer_input_rows_budget(inner_area.height, menu_lines_for_budget); let content_width = usize::from(inner_area.width.max(1)); - let (visible_lines, _cursor_row, _cursor_col) = - layout_input(input_text, input_cursor, content_width, input_rows_budget); + let (visible_lines, _cursor_row, _cursor_col, scroll_offset) = + layout_input_with_scroll(input_text, input_cursor, content_width, input_rows_budget); let is_draft_mode = input_text.contains('\n') || visible_lines.len() > 1; if has_panel { let border_color = if input_text.trim().is_empty() { @@ -666,6 +666,25 @@ impl Renderable for ComposerWidget<'_> { placeholder, Style::default().fg(palette::TEXT_MUTED).italic(), ))); + } else if let Some((sel_start, sel_end)) = self.app.selection_range() { + let line_ranges = visible_line_char_ranges( + &self.app.input, + &visible_lines, + content_width, + scroll_offset, + ); + for (line_text, (line_start, line_end)) in visible_lines.iter().zip(line_ranges.iter()) + { + let spans = line_spans_with_selection( + line_text, + *line_start, + *line_end, + sel_start, + sel_end, + self.app.ui_theme.selection_bg, + ); + input_lines.push(Line::from(spans)); + } } else { for line in &visible_lines { input_lines.push(Line::from(Span::styled( @@ -1938,7 +1957,7 @@ fn build_empty_state_lines(app: &App, area: Rect) -> Vec> { lines } -fn composer_input_rows_budget(inner_height: u16, extra_lines: usize) -> usize { +pub fn composer_input_rows_budget(inner_height: u16, extra_lines: usize) -> usize { usize::from(inner_height).saturating_sub(extra_lines).max(1) } @@ -2251,6 +2270,17 @@ fn layout_input( width: usize, max_height: usize, ) -> (Vec, usize, usize) { + let (visible, visible_cursor_row, visible_cursor_col, _) = + layout_input_with_scroll(input, cursor, width, max_height); + (visible, visible_cursor_row, visible_cursor_col) +} + +pub fn layout_input_with_scroll( + input: &str, + cursor: usize, + width: usize, + max_height: usize, +) -> (Vec, usize, usize, usize) { let mut lines = wrap_input_lines(input, width); if lines.is_empty() { lines.push(String::new()); @@ -2276,6 +2306,7 @@ fn layout_input( visible, visible_cursor_row, cursor_col.min(width.saturating_sub(1)), + start, ) } @@ -2342,6 +2373,34 @@ fn wrap_input_lines(input: &str, width: usize) -> Vec { lines } +/// For mouse coordinate mapping: returns (char_start_of_line, line_text) pairs +/// matching the wrapping produced by `wrap_input_lines`. +pub fn wrap_input_lines_for_mouse(input: &str, width: usize) -> Vec<(usize, String)> { + if input.is_empty() || width == 0 { + return vec![(0, String::new())]; + } + + let mut result = Vec::new(); + let mut char_idx = 0usize; + + for raw_line in input.split('\n') { + if raw_line.is_empty() { + result.push((char_idx, String::new())); + char_idx += 1; // the '\n' + continue; + } + let wrapped = wrap_text(raw_line, width); + for wrapped_line in &wrapped { + let line_char_len: usize = wrapped_line.chars().count(); + result.push((char_idx, wrapped_line.clone())); + char_idx += line_char_len; + } + char_idx += 1; // the '\n' + } + + result +} + fn wrap_text(text: &str, width: usize) -> Vec { if width == 0 { return vec![text.to_string()]; @@ -2383,6 +2442,108 @@ fn wrap_text(text: &str, width: usize) -> Vec { lines } +/// Compute the (char_start, char_end) range for each visible wrapped line. +/// `char_start` is inclusive, `char_end` is exclusive. +/// `scroll_offset` is the number of wrapped lines skipped from the top. +fn visible_line_char_ranges( + input: &str, + visible_lines: &[String], + width: usize, + scroll_offset: usize, +) -> Vec<(usize, usize)> { + if input.is_empty() || width == 0 { + return vec![(0, 0); visible_lines.len()]; + } + + let mut ranges = Vec::new(); + let mut char_idx = 0usize; + let mut line_start = 0usize; + let mut line_width = 0usize; + + for g in input.graphemes(true) { + if g == "\n" { + ranges.push((line_start, char_idx)); + char_idx += 1; + line_start = char_idx; + line_width = 0; + continue; + } + + let gw = g.width(); + if line_width + gw > width && line_width > 0 { + ranges.push((line_start, char_idx)); + line_start = char_idx; + line_width = 0; + } + char_idx += g.chars().count(); + line_width += gw; + if line_width >= width { + ranges.push((line_start, char_idx)); + line_start = char_idx; + line_width = 0; + } + } + ranges.push((line_start, char_idx)); + + // Use the actual scroll_offset to align with visible_lines. + let start = scroll_offset.min(ranges.len()); + ranges + .into_iter() + .skip(start) + .take(visible_lines.len()) + .collect() +} + +fn line_spans_with_selection<'a>( + line: &'a str, + line_start: usize, + line_end: usize, + sel_start: usize, + sel_end: usize, + highlight_bg: Color, +) -> Vec> { + let normal_style = Style::default().fg(palette::TEXT_PRIMARY); + let sel_style = Style::default().fg(palette::TEXT_PRIMARY).bg(highlight_bg); + + // No overlap between this line and the selection + if line_end <= sel_start || line_start >= sel_end { + return vec![Span::styled(line, normal_style)]; + } + + let local_sel_start = sel_start.saturating_sub(line_start); + let local_sel_end = sel_end.min(line_end).saturating_sub(line_start); + + // Build a Vec of byte offsets for each char boundary, plus one past the end. + let mut byte_offsets: Vec = line.char_indices().map(|(i, _)| i).collect(); + byte_offsets.push(line.len()); + + let b0 = byte_offsets + .get(local_sel_start) + .copied() + .unwrap_or(line.len()); + let b1 = byte_offsets + .get(local_sel_end) + .copied() + .unwrap_or(line.len()); + + let mut spans = Vec::with_capacity(3); + + // Text before selection + if b0 > 0 { + spans.push(Span::styled(&line[..b0], normal_style)); + } + // Selected text + if b1 > b0 { + spans.push(Span::styled(&line[b0..b1], sel_style)); + } + // Text after selection + if b1 < line.len() { + spans.push(Span::styled(&line[b1..], normal_style)); + } + + spans +} + #[cfg(test)] mod tests { use super::{ From a554aa9603d46fa6c8333671b1ff33b03e18fd3e Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 13:20:25 -0500 Subject: [PATCH 082/283] feat(project-context): add tracing log when context file is loaded Emit a tracing::info! line with the file path and byte size when a project context file (AGENTS.md, CLAUDE.md, etc.) is successfully loaded. This helps users and maintainers verify which file was used during prompt assembly, addressing the confusion reported in #2227. --- crates/tui/src/project_context.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/tui/src/project_context.rs b/crates/tui/src/project_context.rs index 7ff922d4..39a8ad29 100644 --- a/crates/tui/src/project_context.rs +++ b/crates/tui/src/project_context.rs @@ -384,6 +384,11 @@ pub fn load_project_context(workspace: &Path) -> ProjectContext { if file_path.exists() && file_path.is_file() { match load_context_file(&file_path) { Ok(content) => { + tracing::info!( + "Loaded project context from {} ({} bytes)", + file_path.display(), + content.len() + ); ctx.instructions = Some(content); ctx.source_path = Some(file_path); break; From 1893f797fb94377817d71a05aac66a54e9841f39 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 13:25:55 -0500 Subject: [PATCH 083/283] docs: update CHANGELOG for v0.8.47 work Add entries for deadlock fix (#1856), composer text selection (#2228), and project context loading tracing (#2227). Add community credits for @Fire-dtx and @imkingjh999. --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 007fc825..3ffd8207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 support. Home, End, Ctrl+A, and Ctrl+E now clear the selection to prevent accidental deletions on the next keystroke (#2228). +### Changed + +- **Project context loading now logs the source file.** A tracing info + line is emitted when AGENTS.md, CLAUDE.md, or another context file is + successfully loaded into the system prompt, making it easier to verify + which file was used during prompt assembly (#2227). + ### Fixed - **Deadlock when spawning multiple concurrent sub-agents.** Replaced `RwLock`-based serialisation with a `Semaphore(1)` in `ToolCallRuntime`, preventing re-entrant tool calls from deadlocking on the same lock (#1856). +### Community + +Thanks to contributors whose PRs landed in this release: +**@Fire-dtx** (#1856), +**@imkingjh999** (#2228). + ## [0.8.46] - 2026-05-26 ### Added From 0251b4e8e8a3cbc8e19f45b1894666ddd2de76a0 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 13:39:01 -0500 Subject: [PATCH 084/283] refactor: migrate snapshot and skill_state paths to ~/.codewhale - Add resolve_project_state_dir and ensure_project_state_dir to codewhale-config, providing project-local .codewhale/.deepseek resolution matching the home-directory pattern. - Migrate snapshot paths to prefer ~/.codewhale/snapshots with ~/.deepseek/snapshots fallback (snapshot_base_with_home). - Migrate skill_state.rs to use codewhale_config::ensure_state_dir instead of hardcoded home.join(".deepseek"). - Update doc comments to reference canonical .codewhale paths. Part of #2231 (state-root migration). --- crates/config/src/lib.rs | 30 ++++++++++++++++++++++++++++++ crates/tui/src/skill_state.rs | 8 +++----- crates/tui/src/snapshot/paths.rs | 31 +++++++++++++++++++++---------- 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 9ee6e2fd..ffc78e66 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1618,6 +1618,36 @@ pub fn ensure_state_dir(subdir: &str) -> Result { Ok(dir) } +/// Resolve a project-local state subdirectory, preferring `.codewhale/` +/// when it exists, falling back to `.deepseek/` for legacy projects. +/// +/// Returns `(true, path)` when the primary `.codewhale/` path is used, +/// `(false, path)` for the legacy fallback. The boolean helps callers +/// emit a deprecation notice on legacy paths. +pub fn resolve_project_state_dir( + workspace: &Path, + subdir: &str, +) -> (bool, PathBuf) { + let primary = workspace.join(CODEWHALE_APP_DIR).join(subdir); + if primary.exists() { + return (true, primary); + } + let legacy = workspace.join(LEGACY_APP_DIR).join(subdir); + (false, legacy) +} + +/// Ensure a project-local state subdirectory exists under `.codewhale/`, +/// creating it if necessary. Returns the directory path. +pub fn ensure_project_state_dir( + workspace: &Path, + subdir: &str, +) -> Result { + let dir = workspace.join(CODEWHALE_APP_DIR).join(subdir); + std::fs::create_dir_all(&dir) + .with_context(|| format!("failed to create {}/", dir.display()))?; + Ok(dir) +} + pub fn resolve_config_path(explicit: Option) -> Result { let path = if let Some(path) = explicit { path diff --git a/crates/tui/src/skill_state.rs b/crates/tui/src/skill_state.rs index 4816fa8e..245b51f3 100644 --- a/crates/tui/src/skill_state.rs +++ b/crates/tui/src/skill_state.rs @@ -5,7 +5,7 @@ //! filesystem-discovered `SkillRegistry`: the registry tells us which skills //! exist on disk, and this store tells API clients which ones are marked active. //! -//! Storage shape (TOML at `~/.deepseek/skills_state.toml`): +//! Storage shape (TOML at `~/.codewhale/skills_state.toml`, legacy `~/.deepseek/skills_state.toml`): //! //! ```toml //! disabled = ["skill-name-1", "skill-name-2"] @@ -104,10 +104,8 @@ impl SkillStateStore { } fn default_state_path() -> Result { - let home = dirs::home_dir().context("could not resolve $HOME for ~/.deepseek")?; - let dir = home.join(".deepseek"); - fs::create_dir_all(&dir) - .with_context(|| format!("create deepseek state dir at {}", dir.display()))?; + let dir = codewhale_config::ensure_state_dir(".") + .context("could not resolve or create CodeWhale state directory")?; Ok(dir.join(STATE_FILE_NAME)) } diff --git a/crates/tui/src/snapshot/paths.rs b/crates/tui/src/snapshot/paths.rs index 90d70091..d1ac8c78 100644 --- a/crates/tui/src/snapshot/paths.rs +++ b/crates/tui/src/snapshot/paths.rs @@ -1,18 +1,20 @@ //! Path resolution for the per-workspace snapshot side-repos. //! -//! Snapshots live in `~/.deepseek/snapshots///`. -//! The two-level hash split lets us snapshot multiple worktrees of the same -//! project independently — `git worktree list` users won't get cross-talk -//! between feature branches. +//! Snapshots live under the resolved state directory +//! (`~/.codewhale/snapshots` or legacy `~/.deepseek/snapshots`) with +//! a two-level hash split so we can snapshot multiple worktrees of the +//! same project independently — `git worktree list` users won't get +//! cross-talk between feature branches. use std::io; use std::path::{Path, PathBuf}; /// Compute the snapshot directory for a given workspace path. /// -/// Returns `~/.deepseek/snapshots///`. The -/// caller is responsible for creating it on disk; we purposefully don't -/// touch the filesystem here so this is cheap to call repeatedly. +/// Returns `$STATE_DIR/snapshots///` where +/// `$STATE_DIR` is resolved via `codewhale_config::resolve_state_dir`. +/// The caller is responsible for creating it on disk; we purposefully +/// don't touch the filesystem here so this is cheap to call repeatedly. /// /// The `project_hash` is derived from the canonicalized workspace path /// after stripping any `.worktrees/` suffix — multiple worktrees @@ -24,7 +26,7 @@ pub fn snapshot_dir_for(workspace: &Path) -> PathBuf { } /// Same as [`snapshot_dir_for`] but with an injectable home directory. -/// Used by tests so we never touch the user's real `~/.deepseek/`. +/// Used by tests so they never touch the user's real state directory. pub fn snapshot_dir_with_home(workspace: &Path, home: Option) -> PathBuf { let home = home.unwrap_or_else(|| PathBuf::from(".")); let canonical = workspace @@ -33,12 +35,21 @@ pub fn snapshot_dir_with_home(workspace: &Path, home: Option) -> PathBu let project_root = strip_worktree_suffix(&canonical); let project_hash = stable_hex(&project_root); let worktree_hash = stable_hex(&canonical); - home.join(".deepseek") - .join("snapshots") + snapshot_base_with_home(Some(home)) .join(project_hash) .join(worktree_hash) } +fn snapshot_base_with_home(home: Option) -> PathBuf { + let home = home.unwrap_or_else(|| PathBuf::from(".")); + // Prefer .codewhale, fall back to .deepseek + let primary = home.join(".codewhale").join("snapshots"); + if primary.exists() { + return primary; + } + home.join(".deepseek").join("snapshots") +} + /// Resolve the `.git` directory inside the snapshot dir. pub fn snapshot_git_dir(workspace: &Path) -> PathBuf { snapshot_dir_for(workspace).join(".git") From b9596a9640a81bff590ed11ba36cd680ea336c96 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 13:39:57 -0500 Subject: [PATCH 085/283] fix(tui): flush active cell before inserting steer message in transcript Steered/queued user messages were being inserted into app.history before the active cell (holding streaming thinking/tool content) was flushed, causing the user's message to render above (before) the thinking block that chronologically preceded it. Now call app.flush_active_cell() before app.add_message() in steer_user_message(), matching the pattern used in MessageStarted and MessageDelta handlers. Fixes #2225. --- crates/tui/src/tui/ui.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 7c77715d..c0f1dad4 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -5384,6 +5384,11 @@ async fn steer_user_message( engine_handle.steer(content.clone()).await?; app.last_submitted_prompt = Some(message.display.clone()); + // Flush any streaming thinking/tool content into history before + // inserting the steer message, so the steer appears after (below) + // the content that chronologically preceded it. + app.flush_active_cell(); + // Mirror steer input in local transcript/session state. app.add_message(HistoryCell::User { content: format!("+ {}", message.display), From 9540af268f11b2a741cfe720bd271be96bf7bd94 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 13:45:18 -0500 Subject: [PATCH 086/283] fix(tests): update save-default-path test for managed sessions dir The /save command now writes to ~/.codewhale/sessions (or legacy ~/.deepseek/sessions) instead of the workspace root. Update the test to set CODEWHALE_HOME to a temp directory and pre-create the sessions subdirectory so resolve_state_dir picks the primary path. Fixes the first failing test in #2223. --- crates/tui/src/commands/session.rs | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index bc51683d..316e8c6c 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -487,22 +487,33 @@ mod tests { } #[test] - fn test_save_with_default_path_uses_workspace() { + fn test_save_with_default_path_uses_managed_sessions_dir() { let tmpdir = TempDir::new().unwrap(); + // Set CODEWHALE_HOME so the managed sessions directory lands inside the + // temp dir rather than the real user home. Pre-create the directory so + // resolve_state_dir picks it up instead of falling back to legacy. + let home = tmpdir.path().join("home"); + let sessions_dir = home.join("sessions"); + std::fs::create_dir_all(&sessions_dir).unwrap(); + // SAFETY: test-only, single-threaded via cargo test + unsafe { std::env::set_var("CODEWHALE_HOME", home.to_str().unwrap()) }; let mut app = create_test_app_with_tmpdir(&tmpdir); let result = save(&mut app, None); assert!(result.message.is_some()); let msg = result.message.unwrap(); - // Should create file in workspace with timestamp name // Give it a moment to ensure file is written std::thread::sleep(std::time::Duration::from_millis(10)); - let entries: Vec<_> = std::fs::read_dir(tmpdir.path()) - .unwrap() - .filter_map(|e| e.ok()) - .filter(|e| e.file_name().to_string_lossy().starts_with("session_")) - .collect(); - // Test passes if file was created or if save returned success message - assert!(!entries.is_empty() || msg.contains("Session saved")); + let entries: Vec<_> = if sessions_dir.exists() { + std::fs::read_dir(&sessions_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().starts_with("session_")) + .collect() + } else { + Vec::new() + }; + // Session should be saved to the managed dir, not the workspace root. + assert!(!entries.is_empty(), "expected session file in {sessions_dir:?}, got none; msg: {msg}"); } #[test] From 187014d7d5760390a121dbb11c566c34f5d7dcea Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 13:47:36 -0500 Subject: [PATCH 087/283] docs: update README paths from ~/.deepseek to ~/.codewhale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update all three READMEs (en, zh-CN, ja-JP) to use the canonical ~/.codewhale paths for config, skills, and Docker volume mounts, with legacy ~/.deepseek noted as a compatibility fallback. The state-root migration has been underway since v0.8.44 — the docs now reflect it. --- README.ja-JP.md | 12 ++++++------ README.md | 14 +++++++------- README.zh-CN.md | 18 +++++++++--------- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/README.ja-JP.md b/README.ja-JP.md index 813cefea..667aafb5 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -33,7 +33,7 @@ brew install deepseek-tui docker volume create codewhale-home docker run --rm -it \ -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ - -v codewhale-home:/home/codewhale/.deepseek \ + -v codewhale-home:/home/codewhale/.codewhale \ -v "$PWD:/workspace" \ -w /workspace \ ghcr.io/hmbown/codewhale:latest @@ -118,12 +118,12 @@ codewhale --model auto ビルド済みバイナリペアとプラットフォームアーカイブは **Linux x64**、**Linux ARM64**(v0.8.8 以降)、**macOS x64**、**macOS ARM64**、**Windows x64** 向けに公開されています。その他のターゲット(musl、riscv64、FreeBSD など)は [ソースからのインストール](#install-from-source) または [docs/INSTALL.md](docs/INSTALL.md) を参照してください。 -初回起動時に [DeepSeek API キー](https://platform.deepseek.com/api_keys) の入力を求められます。キーは `~/.deepseek/config.toml` に保存されるため、OS のクレデンシャルプロンプトなしに任意のディレクトリから利用できます。 +初回起動時に [DeepSeek API キー](https://platform.deepseek.com/api_keys) の入力を求められます。キーは `~/.codewhale/config.toml`(旧 `~/.deepseek/config.toml` も互換性維持)に保存されるため、OS のクレデンシャルプロンプトなしに任意のディレクトリから利用できます。 事前に設定することもできます: ```bash -codewhale auth set --provider deepseek # ~/.deepseek/config.toml に保存 +codewhale auth set --provider deepseek # ~/.codewhale/config.toml に保存 export DEEPSEEK_API_KEY="YOUR_KEY" # 環境変数による代替方法。非対話シェルでは ~/.zshenv を使用 codewhale @@ -308,7 +308,7 @@ codewhale update # バイナリ更新の確認 ## 設定 -ユーザー設定: `~/.deepseek/config.toml`。プロジェクトオーバーレイ: `/.deepseek/config.toml`(拒否される項目: `api_key`、`base_url`、`provider`、`mcp_config_path`)。すべてのオプションは [config.example.toml](config.example.toml) にあります。 +ユーザー設定: `~/.codewhale/config.toml`(旧 `~/.deepseek/config.toml` も互換性維持)。プロジェクトオーバーレイ: `/.codewhale/config.toml`(旧 `/.deepseek/config.toml`)(拒否される項目: `api_key`、`base_url`、`provider`、`mcp_config_path`)。すべてのオプションは [config.example.toml](config.example.toml) にあります。 主な環境変数: @@ -359,10 +359,10 @@ UI のロケールはモデルの言語とは別です。`settings.toml` で `lo ## 自分のスキルを公開する -codewhale はワークスペースのディレクトリ(`.agents/skills` → `skills` → `.opencode/skills` → `.claude/skills`)とグローバルな `~/.deepseek/skills` からスキルを発見します。各スキルは `SKILL.md` ファイルを持つディレクトリです: +codewhale はワークスペースのディレクトリ(`.agents/skills` → `skills` → `.opencode/skills` → `.claude/skills`)とグローバルな `~/.codewhale/skills`(旧 `~/.deepseek/skills` も互換性維持)からスキルを発見します。各スキルは `SKILL.md` ファイルを持つディレクトリです: ```text -~/.deepseek/skills/my-skill/ +~/.codewhale/skills/my-skill/ └── SKILL.md ``` diff --git a/README.md b/README.md index 334ae74f..5531f242 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ brew install deepseek-tui docker volume create codewhale-home docker run --rm -it \ -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ - -v codewhale-home:/home/codewhale/.deepseek \ + -v codewhale-home:/home/codewhale/.codewhale \ -v "$PWD:/workspace" \ -w /workspace \ ghcr.io/hmbown/codewhale:latest @@ -176,12 +176,12 @@ codewhale --model auto Prebuilt binary pairs and platform archives are published for **Linux x64**, **Linux ARM64** (v0.8.8+), **macOS x64**, **macOS ARM64**, and **Windows x64**. For other targets (musl, riscv64, FreeBSD, etc.), see [Install from source](#install-from-source) or [docs/INSTALL.md](docs/INSTALL.md). -On first launch you'll be prompted for your [DeepSeek API key](https://platform.deepseek.com/api_keys). The key is saved to `~/.deepseek/config.toml` so it works from any directory without OS credential prompts. +On first launch you'll be prompted for your [DeepSeek API key](https://platform.deepseek.com/api_keys). The key is saved to `~/.codewhale/config.toml` (legacy `~/.deepseek/config.toml` also supported) so it works from any directory without OS credential prompts. You can also set it ahead of time: ```bash -codewhale auth set --provider deepseek # saves to ~/.deepseek/config.toml +codewhale auth set --provider deepseek # saves to ~/.codewhale/config.toml codewhale auth status # shows the active credential source export DEEPSEEK_API_KEY="YOUR_KEY" # env var alternative; use ~/.zshenv for non-interactive shells @@ -395,7 +395,7 @@ docker volume create codewhale-home docker run --rm -it \ -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ - -v codewhale-home:/home/codewhale/.deepseek \ + -v codewhale-home:/home/codewhale/.codewhale \ -v "$PWD:/workspace" \ -w /workspace \ ghcr.io/hmbown/codewhale:latest @@ -461,7 +461,7 @@ Full shortcut catalog: [docs/KEYBINDINGS.md](docs/KEYBINDINGS.md). ## Configuration -User config: `~/.deepseek/config.toml`. Project overlay: `/.deepseek/config.toml` (denied: `api_key`, `base_url`, `provider`, `mcp_config_path`). [config.example.toml](config.example.toml) has every option. +User config: `~/.codewhale/config.toml` (legacy `~/.deepseek/config.toml` fallback). Project overlay: `/.codewhale/config.toml` (legacy `/.deepseek/config.toml`) (denied: `api_key`, `base_url`, `provider`, `mcp_config_path`). [config.example.toml](config.example.toml) has every option. Key environment variables: @@ -514,7 +514,7 @@ Legacy aliases `deepseek-chat` / `deepseek-reasoner` map to `deepseek-v4-flash` ## Publishing Your Own Skill -codewhale discovers skills from workspace directories (`.agents/skills` → `skills` → `.opencode/skills` → `.claude/skills` → `.cursor/skills`) and global directories (`~/.agents/skills` → `~/.claude/skills` → `~/.deepseek/skills`). Each skill is a directory with a `SKILL.md` file: +codewhale discovers skills from workspace directories (`.agents/skills` → `skills` → `.opencode/skills` → `.claude/skills` → `.cursor/skills`) and global directories (`~/.agents/skills` → `~/.claude/skills` → `~/.codewhale/skills` → `~/.deepseek/skills`). Each skill is a directory with a `SKILL.md` file: ```text ~/.agents/skills/my-skill/ @@ -539,7 +539,7 @@ First launch also installs bundled system skills for common workflows: `skill-creator`, `delegate`, `v4-best-practices`, `plugin-creator`, `skill-installer`, `mcp-builder`, `documents`, `presentations`, `spreadsheets`, `pdf`, and `feishu`. These live under -`~/.deepseek/skills` and are versioned so new bundles are added on upgrade +`~/.codewhale/skills` (or legacy `~/.deepseek/skills`) and are versioned so new bundles are added on upgrade without recreating skills the user deliberately deleted. --- diff --git a/README.zh-CN.md b/README.zh-CN.md index 314b5955..f079cc80 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -36,7 +36,7 @@ brew install deepseek-tui docker volume create codewhale-home docker run --rm -it \ -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ - -v codewhale-home:/home/codewhale/.deepseek \ + -v codewhale-home:/home/codewhale/.codewhale \ -v "$PWD:/workspace" \ -w /workspace \ ghcr.io/hmbown/codewhale:latest @@ -136,12 +136,12 @@ codewhale --model auto 预构建二进制对和平台压缩包覆盖 **Linux x64**、**Linux ARM64**(v0.8.8 起)、**macOS x64**、**macOS ARM64** 和 **Windows x64**。其他目标平台(musl、riscv64、FreeBSD 等)请见下方的[从源码安装](#从源码安装)或 [docs/INSTALL.md](docs/INSTALL.md)。 -首次启动时会提示输入 [DeepSeek API key](https://platform.deepseek.com/api_keys)。密钥保存到 `~/.deepseek/config.toml`,在任意目录、IDE 终端和脚本中都能使用,不会触发系统密钥环弹窗。 +首次启动时会提示输入 [DeepSeek API key](https://platform.deepseek.com/api_keys)。密钥保存到 `~/.codewhale/config.toml`(同时兼容旧版 `~/.deepseek/config.toml`),在任意目录、IDE 终端和脚本中都能使用,不会触发系统密钥环弹窗。 也可以提前配置: ```bash -codewhale auth set --provider deepseek # 保存到 ~/.deepseek/config.toml +codewhale auth set --provider deepseek # 保存到 ~/.codewhale/config.toml codewhale auth status # 显示当前活跃的凭证来源 export DEEPSEEK_API_KEY="YOUR_KEY" # 环境变量方式;需要在非交互式 shell 中使用请放入 ~/.zshenv @@ -331,7 +331,7 @@ docker volume create codewhale-home docker run --rm -it \ -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ - -v codewhale-home:/home/codewhale/.deepseek \ + -v codewhale-home:/home/codewhale/.codewhale \ -v "$PWD:/workspace" \ -w /workspace \ ghcr.io/hmbown/codewhale:latest @@ -389,7 +389,7 @@ DeepSeek 可作为自定义 Agent Client Protocol 服务器运行,供 Zed 等 ## 配置 -用户配置:`~/.deepseek/config.toml`。项目覆盖:`/.deepseek/config.toml`(以下密钥被拒绝:`api_key`、`base_url`、`provider`、`mcp_config_path`)。完整选项见 [config.example.toml](config.example.toml)。 +用户配置:`~/.codewhale/config.toml`(兼容旧版 `~/.deepseek/config.toml`)。项目覆盖:`/.codewhale/config.toml`(兼容 `/.deepseek/config.toml`)(以下密钥被拒绝:`api_key`、`base_url`、`provider`、`mcp_config_path`)。完整选项见 [config.example.toml](config.example.toml)。 常用环境变量: @@ -431,10 +431,10 @@ DeepSeek 可作为自定义 Agent Client Protocol 服务器运行,供 Zed 等 可选语言:`auto` | `en` | `ja` | `zh-Hans` | `pt-BR`。 -也可以在 `~/.deepseek/config.toml` 里直接设置 `locale = "zh-Hans"`,或通过 `LC_ALL` / `LANG` 环境变量自动选择: +也可以在 `~/.codewhale/config.toml` 里直接设置 `locale = "zh-Hans"`,或通过 `LC_ALL` / `LANG` 环境变量自动选择: ```toml -# ~/.deepseek/config.toml +# ~/.codewhale/config.toml [tui] locale = "zh-Hans" ``` @@ -463,10 +463,10 @@ LANG=zh_CN.UTF-8 codewhale run ## 创建和安装技能 -codewhale 从工作区目录(`.agents/skills` → `skills` → `.opencode/skills` → `.claude/skills`)和全局 `~/.deepseek/skills` 发现技能。每个技能是一个包含 `SKILL.md` 的目录: +codewhale 从工作区目录(`.agents/skills` → `skills` → `.opencode/skills` → `.claude/skills`)和全局 `~/.codewhale/skills`(兼容旧版 `~/.deepseek/skills`)发现技能。每个技能是一个包含 `SKILL.md` 的目录: ```text -~/.deepseek/skills/my-skill/ +~/.codewhale/skills/my-skill/ └── SKILL.md ``` From f23f4244923afb4a530f9da49277e13ff61aad12 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 13:47:54 -0500 Subject: [PATCH 088/283] docs: update CHANGELOG for v0.8.47 session 2 work Add entries for steer message ordering fix, state-root migration progress, README path updates, and session save test fix. --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ffd8207..291b80e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,12 +20,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 line is emitted when AGENTS.md, CLAUDE.md, or another context file is successfully loaded into the system prompt, making it easier to verify which file was used during prompt assembly (#2227). +- **State-root migration continues.** Snapshot directory resolution now + prefers `~/.codewhale/snapshots` with `~/.deepseek/snapshots` fallback. + Skill state storage migrated to `~/.codewhale/skills_state.toml`. + Added `resolve_project_state_dir` and `ensure_project_state_dir` to + `codewhale-config` for project-local `.codewhale/` vs `.deepseek/` + resolution (#2231). +- **READMEs updated for the CodeWhale rename.** All three READMEs now + reference canonical `~/.codewhale` paths for config, skills, and Docker + volumes, with legacy `~/.deepseek` noted as a compatibility fallback. ### Fixed - **Deadlock when spawning multiple concurrent sub-agents.** Replaced `RwLock`-based serialisation with a `Semaphore(1)` in `ToolCallRuntime`, preventing re-entrant tool calls from deadlocking on the same lock (#1856). +- **Steered/queued messages now render in correct transcript order.** + `steer_user_message` now flushes the active cell into history before + inserting the steer message, so the user's message appears after + (below) the thinking content that chronologically preceded it (#2225). +- **Session save test updated for managed sessions directory.** The + `/save` command now writes to `~/.codewhale/sessions` (or legacy + `~/.deepseek/sessions`) instead of the workspace root. Test updated + to set `CODEWHALE_HOME` and pre-create the sessions directory (#2223). ### Community From a1a7b5709ac949ed6cd9768d9b53efe6003e4b90 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 13:50:31 -0500 Subject: [PATCH 089/283] refactor: migrate more state paths to ~/.codewhale with legacy fallback - Spillover directory (truncate.rs): prefer ~/.codewhale/tool_outputs - Memory storage (capacity_memory.rs): prefer ~/.codewhale/memory - Runtime logs (runtime_log.rs): prefer ~/.codewhale/logs - Crash dumps (utils.rs): prefer ~/.codewhale/crashes - Automations (automation_manager.rs): prefer ~/.codewhale/automations - TUI settings (settings.rs): prefer ~/.codewhale/tui.toml All paths fall back to the legacy ~/.deepseek location when the canonical path doesn't exist, preserving compatibility for existing installs. Part of #2231. --- crates/tui/src/automation_manager.rs | 11 +++++++++-- crates/tui/src/core/capacity_memory.rs | 15 +++++++++++---- crates/tui/src/runtime_log.rs | 13 ++++++++++--- crates/tui/src/settings.rs | 4 ++++ crates/tui/src/tools/truncate.rs | 5 +++++ crates/tui/src/utils.rs | 12 +++++++++++- 6 files changed, 50 insertions(+), 10 deletions(-) diff --git a/crates/tui/src/automation_manager.rs b/crates/tui/src/automation_manager.rs index c98dc7e8..79bc8765 100644 --- a/crates/tui/src/automation_manager.rs +++ b/crates/tui/src/automation_manager.rs @@ -795,8 +795,15 @@ pub fn default_automations_dir() -> PathBuf { } } dirs::home_dir() - .map(|home| home.join(".deepseek").join("automations")) - .unwrap_or_else(|| PathBuf::from(".deepseek").join("automations")) + .map(|home| { + let primary = home.join(".codewhale").join("automations"); + if primary.exists() { + primary + } else { + home.join(".deepseek").join("automations") + } + }) + .unwrap_or_else(|| PathBuf::from(".codewhale").join("automations")) } pub type SharedAutomationManager = Arc>; diff --git a/crates/tui/src/core/capacity_memory.rs b/crates/tui/src/core/capacity_memory.rs index f41bd48a..ab598512 100644 --- a/crates/tui/src/core/capacity_memory.rs +++ b/crates/tui/src/core/capacity_memory.rs @@ -56,14 +56,21 @@ fn capacity_memory_dirs() -> Vec { let mut dirs = Vec::new(); if let Some(home) = dirs::home_dir() { + // Prefer .codewhale, fall back to .deepseek + let primary = home.join(".codewhale").join("memory"); + if primary.exists() { + dirs.push(primary); + } dirs.push(home.join(".deepseek").join("memory")); } let cwd = std::env::current_dir() - .unwrap_or_else(|_| PathBuf::from(".")) - .join(".deepseek") - .join("memory"); - dirs.push(cwd); + .unwrap_or_else(|_| PathBuf::from(".")); + let primary_cwd = cwd.join(".codewhale").join("memory"); + if primary_cwd.exists() { + dirs.push(primary_cwd); + } + dirs.push(cwd.join(".deepseek").join("memory")); dirs.dedup(); dirs diff --git a/crates/tui/src/runtime_log.rs b/crates/tui/src/runtime_log.rs index 7fa0e8ca..48373d4b 100644 --- a/crates/tui/src/runtime_log.rs +++ b/crates/tui/src/runtime_log.rs @@ -157,17 +157,24 @@ pub fn init() -> Result { } fn log_directory() -> Option { + let resolve = |base: PathBuf| -> Option { + let primary = base.join(".codewhale").join("logs"); + if primary.exists() { + return Some(primary); + } + Some(base.join(".deepseek").join("logs")) + }; if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) && !home.as_os_str().is_empty() { - return Some(home.join(".deepseek").join("logs")); + return resolve(home); } if let Some(userprofile) = std::env::var_os("USERPROFILE").map(PathBuf::from) && !userprofile.as_os_str().is_empty() { - return Some(userprofile.join(".deepseek").join("logs")); + return resolve(userprofile); } - dirs::home_dir().map(|h| h.join(".deepseek").join("logs")) + dirs::home_dir().and_then(|h| resolve(h)) } fn log_file_name(date: &str, pid: u32) -> String { diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 1a92ef2f..040ba88f 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -109,6 +109,10 @@ impl TuiPrefs { let home = dirs::home_dir() .context("Failed to resolve home directory: cannot determine tui.toml path.")?; + let primary = home.join(".codewhale").join("tui.toml"); + if primary.exists() { + return Ok(primary); + } Ok(home.join(".deepseek").join("tui.toml")) } diff --git a/crates/tui/src/tools/truncate.rs b/crates/tui/src/tools/truncate.rs index e0cadcae..6c8d6e69 100644 --- a/crates/tui/src/tools/truncate.rs +++ b/crates/tui/src/tools/truncate.rs @@ -81,6 +81,11 @@ pub fn spillover_root() -> Option { return Some(root); } + // Prefer .codewhale, fall back to .deepseek + let primary = dirs::home_dir()?.join(".codewhale").join(SPILLOVER_DIR_NAME); + if primary.exists() { + return Some(primary); + } Some(dirs::home_dir()?.join(".deepseek").join(SPILLOVER_DIR_NAME)) } diff --git a/crates/tui/src/utils.rs b/crates/tui/src/utils.rs index a260e1d5..15c23199 100644 --- a/crates/tui/src/utils.rs +++ b/crates/tui/src/utils.rs @@ -259,7 +259,17 @@ fn write_panic_dump( let home = dirs::home_dir().ok_or_else(|| { std::io::Error::new(std::io::ErrorKind::NotFound, "home directory not found") })?; - let crash_dir = home.join(".deepseek").join("crashes"); + // Prefer .codewhale, fall back to .deepseek + let crash_dir = home.join(".codewhale").join("crashes"); + if !crash_dir.exists() { + // Try legacy path for reading, but prefer new for writing + let _ = std::fs::create_dir_all(&crash_dir); + } + let crash_dir = if crash_dir.exists() { + crash_dir + } else { + home.join(".deepseek").join("crashes") + }; write_panic_dump_to(&crash_dir, name, location, message) } From 4925be4ddaf51581daa044d8a3de9e467d7c611b Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 14:15:57 -0500 Subject: [PATCH 090/283] refactor: migrate handoff, notes, mcp, subagent, recall, anchors to .codewhale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HANDOFF_RELATIVE_PATH → .codewhale/handoff.md with .deepseek fallback - load_handoff_block reads both paths, prefers .codewhale - ToolContext notes_path and mcp_config_path use resolve_project_state_dir - Sub-agent state path prefers .codewhale/state/ - Cycle archive (recall_archive) uses resolve_state_dir for sessions - Compaction anchors path prefers .codewhale/anchors.md - Updated marker constants and comments Part of #2231. --- crates/tui/src/compaction.rs | 8 +++++++- crates/tui/src/prompts.rs | 17 ++++++++++++----- crates/tui/src/tools/recall_archive.rs | 9 ++++----- crates/tui/src/tools/spec.rs | 5 +++-- crates/tui/src/tools/subagent/mod.rs | 5 +++++ 5 files changed, 31 insertions(+), 13 deletions(-) diff --git a/crates/tui/src/compaction.rs b/crates/tui/src/compaction.rs index 460eb9e0..4048524d 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -1032,7 +1032,13 @@ fn read_workspace_anchors(workspace: Option<&Path>) -> Vec { return Vec::new(); }; - let anchors_path = ws.join(".deepseek").join("anchors.md"); + // Prefer .codewhale, fall back to .deepseek + let primary = ws.join(".codewhale").join("anchors.md"); + let anchors_path = if primary.exists() { + primary + } else { + ws.join(".deepseek").join("anchors.md") + }; let Ok(content) = std::fs::read_to_string(anchors_path) else { return Vec::new(); }; diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index aa69f4f7..6a60ab33 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -53,7 +53,9 @@ impl Default for PromptSessionContext<'_> { /// A previous session writes it on exit / `/compact`; the next session reads /// it back on startup and prepends it to the system prompt so a fresh agent /// doesn't have to re-discover open blockers from scratch. -pub const HANDOFF_RELATIVE_PATH: &str = ".deepseek/handoff.md"; +pub const HANDOFF_RELATIVE_PATH: &str = ".codewhale/handoff.md"; +/// Legacy handoff path for reading from existing installs. +const LEGACY_HANDOFF_RELATIVE_PATH: &str = ".deepseek/handoff.md"; /// Per-file size cap for `instructions = [...]` entries (#454). Mirrors /// the existing project-context cap in `project_context::load_context_file` @@ -180,7 +182,12 @@ fn render_instructions_block(paths: &[PathBuf]) -> Option { /// system-prompt block. Returns `None` when the file is absent or empty so /// callers can keep the default-uncluttered prompt for fresh workspaces. fn load_handoff_block(workspace: &Path) -> Option { - let path = workspace.join(HANDOFF_RELATIVE_PATH); + let primary = workspace.join(HANDOFF_RELATIVE_PATH); + let path = if primary.exists() { + primary + } else { + workspace.join(LEGACY_HANDOFF_RELATIVE_PATH) + }; let raw = std::fs::read_to_string(&path).ok()?; let trimmed = raw.trim(); if trimmed.is_empty() { @@ -373,7 +380,7 @@ pub const SUGGEST_APPROVAL: &str = include_str!("prompts/approvals/suggest.md"); pub const NEVER_APPROVAL: &str = include_str!("prompts/approvals/never.md"); /// Compaction relay template — written into the system prompt so the -/// model knows the format to use when writing `.deepseek/handoff.md`. +/// model knows the format to use when writing `.codewhale/handoff.md`. pub const COMPACT_TEMPLATE: &str = include_str!("prompts/compact.md"); /// Memory hygiene guidance — appended to the system prompt only when the @@ -741,7 +748,7 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval( } // 5. Compaction relay template — so the model knows the format to use - // when writing `.deepseek/handoff.md` on exit / `/compact`. + // when writing `.codewhale/handoff.md` on exit / `/compact`. full_prompt.push_str("\n\n"); full_prompt.push_str(COMPACT_TEMPLATE); @@ -832,7 +839,7 @@ mod tests { /// Discriminator unique to the injected relay block (not present in the /// agent prompt's own discussion of the convention). - const HANDOFF_BLOCK_MARKER: &str = "left a relay artifact at `.deepseek/handoff.md`"; + const HANDOFF_BLOCK_MARKER: &str = "left a relay artifact at `.codewhale/handoff.md`"; fn contains_cjk(text: &str) -> bool { text.chars().any(|ch| { diff --git a/crates/tui/src/tools/recall_archive.rs b/crates/tui/src/tools/recall_archive.rs index 380d11ad..6ec0b1a6 100644 --- a/crates/tui/src/tools/recall_archive.rs +++ b/crates/tui/src/tools/recall_archive.rs @@ -162,11 +162,10 @@ fn archive_root(session_id: &str) -> Result { "Could not resolve home directory for cycle archive root", ) })?; - Ok(home - .join(".deepseek") - .join("sessions") - .join(session_id) - .join("cycles")) + // Use resolved sessions dir (prefers ~/.codewhale/sessions) + let sessions = codewhale_config::resolve_state_dir("sessions") + .unwrap_or_else(|_| home.join(".deepseek").join("sessions")); + Ok(sessions.join(session_id).join("cycles")) } /// Enumerate all archive files for a session, sorted by cycle number ascending. diff --git a/crates/tui/src/tools/spec.rs b/crates/tui/src/tools/spec.rs index f13c6516..af6c0ce0 100644 --- a/crates/tui/src/tools/spec.rs +++ b/crates/tui/src/tools/spec.rs @@ -183,8 +183,9 @@ impl ToolContext { pub fn new(workspace: impl Into) -> Self { let workspace = workspace.into(); let shell_manager = new_shared_shell_manager(workspace.clone()); - let notes_path = workspace.join(".deepseek").join("notes.md"); - let mcp_config_path = workspace.join(".deepseek").join("mcp.json"); + // Prefer .codewhale, fall back to .deepseek for project-local state + let notes_path = codewhale_config::resolve_project_state_dir(&workspace, "notes.md").1; + let mcp_config_path = codewhale_config::resolve_project_state_dir(&workspace, "mcp.json").1; Self { workspace, shell_manager, diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index d50972ec..357aeec4 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -1845,6 +1845,11 @@ async fn subagent_session_projection( } fn default_state_path(workspace: &Path) -> PathBuf { + // Prefer .codewhale, fall back to .deepseek for project-local state + let primary = workspace.join(".codewhale").join("state"); + if primary.exists() { + return primary.join(SUBAGENT_STATE_FILE); + } workspace .join(".deepseek") .join("state") From 0706285bfebee76ba746150ef0edf313f7fae333 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 14:19:22 -0500 Subject: [PATCH 091/283] feat(update): add CNB mirror support for China-friendly binary downloads - Add CODEWHALE_RELEASE_BASE_URL as canonical env override for release asset base URL (DEEPSEEK_TUI_RELEASE_BASE_URL and DEEPSEEK_RELEASE_BASE_URL remain as legacy fallbacks). - Add CODEWHALE_USE_CNB_MIRROR env var to auto-select the CNB (cnb.cool) mirror for binary downloads, avoiding GitHub Releases timeouts in China. - Update npm install scripts (artifacts.js) with the same env checks. - Update Rust self-updater (update.rs) with new constants and env cascade. Fixes #2222. --- crates/cli/src/update.rs | 31 +++++++++++++++++++++++------- npm/codewhale/scripts/artifacts.js | 11 ++++++++++- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/crates/cli/src/update.rs b/crates/cli/src/update.rs index 9205c899..5b1dce53 100644 --- a/crates/cli/src/update.rs +++ b/crates/cli/src/update.rs @@ -15,8 +15,12 @@ const CHECKSUM_MANIFEST_ASSET: &str = "codewhale-artifacts-sha256.txt"; const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/Hmbown/CodeWhale/releases/latest"; const RELEASES_URL: &str = "https://api.github.com/repos/Hmbown/CodeWhale/releases?per_page=100"; const CNB_REPO_URL: &str = "https://cnb.cool/codewhale.net/codewhale"; -const RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_TUI_RELEASE_BASE_URL"; -const LEGACY_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_RELEASE_BASE_URL"; +const RELEASE_BASE_URL_ENV: &str = "CODEWHALE_RELEASE_BASE_URL"; +const LEGACY_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_TUI_RELEASE_BASE_URL"; +const DEEPSEEK_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_RELEASE_BASE_URL"; +const CNB_MIRROR_ENV: &str = "CODEWHALE_USE_CNB_MIRROR"; +/// Base URL for CNB binary release asset downloads (China-friendly mirror). +const CNB_RELEASE_ASSET_BASE: &str = "https://cnb.cool/Hmbown/CodeWhale/-/releases"; const UPDATE_VERSION_ENV: &str = "DEEPSEEK_TUI_VERSION"; const LEGACY_UPDATE_VERSION_ENV: &str = "DEEPSEEK_VERSION"; const UPDATE_USER_AGENT: &str = "codewhale-updater"; @@ -370,11 +374,24 @@ fn fetch_latest_release(channel: ReleaseChannel) -> Result { } fn release_base_url_from_env() -> Option { - std::env::var(RELEASE_BASE_URL_ENV) - .ok() - .or_else(|| std::env::var(LEGACY_RELEASE_BASE_URL_ENV).ok()) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) + // Check canonical env first, then legacy envs + for env_name in [ + RELEASE_BASE_URL_ENV, + LEGACY_RELEASE_BASE_URL_ENV, + DEEPSEEK_RELEASE_BASE_URL_ENV, + ] { + if let Ok(value) = std::env::var(env_name) { + let trimmed = value.trim().to_string(); + if !trimmed.is_empty() { + return Some(trimmed); + } + } + } + // Auto-detect CNB mirror when CODEWHALE_USE_CNB_MIRROR is set + if std::env::var(CNB_MIRROR_ENV).is_ok() { + return Some(CNB_RELEASE_ASSET_BASE.to_string()); + } + None } fn update_version_from_env() -> Option { diff --git a/npm/codewhale/scripts/artifacts.js b/npm/codewhale/scripts/artifacts.js index 27117b0c..10645404 100644 --- a/npm/codewhale/scripts/artifacts.js +++ b/npm/codewhale/scripts/artifacts.js @@ -78,12 +78,21 @@ function executableName(base, platform) { } function releaseBaseUrl(version, repo = "Hmbown/CodeWhale") { + // CODEWHALE_RELEASE_BASE_URL is the canonical override. + // DEEPSEEK_TUI_RELEASE_BASE_URL / DEEPSEEK_RELEASE_BASE_URL are legacy aliases. const override = - process.env.DEEPSEEK_TUI_RELEASE_BASE_URL || process.env.DEEPSEEK_RELEASE_BASE_URL; + process.env.CODEWHALE_RELEASE_BASE_URL || + process.env.DEEPSEEK_TUI_RELEASE_BASE_URL || + process.env.DEEPSEEK_RELEASE_BASE_URL; if (override) { const trimmed = String(override).trim(); return trimmed.endsWith("/") ? trimmed : `${trimmed}/`; } + // When CODEWHALE_USE_CNB_MIRROR is set, use the CNB (China-friendly) + // mirror that already builds and publishes binary release assets. + if (process.env.CODEWHALE_USE_CNB_MIRROR) { + return `https://cnb.cool/Hmbown/CodeWhale/-/releases/v${version}/`; + } return `https://github.com/${repo}/releases/download/v${version}/`; } From 8ed4301d3581d0c4056bdfb8d94d3a4f897d2f71 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 14:19:53 -0500 Subject: [PATCH 092/283] docs: update CHANGELOG for CNB mirror and state-root migration --- CHANGELOG.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 291b80e3..5616eaa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,12 +20,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 line is emitted when AGENTS.md, CLAUDE.md, or another context file is successfully loaded into the system prompt, making it easier to verify which file was used during prompt assembly (#2227). -- **State-root migration continues.** Snapshot directory resolution now - prefers `~/.codewhale/snapshots` with `~/.deepseek/snapshots` fallback. - Skill state storage migrated to `~/.codewhale/skills_state.toml`. - Added `resolve_project_state_dir` and `ensure_project_state_dir` to - `codewhale-config` for project-local `.codewhale/` vs `.deepseek/` - resolution (#2231). +- **CNB mirror support for China-friendly downloads.** Added + `CODEWHALE_RELEASE_BASE_URL` env var and `CODEWHALE_USE_CNB_MIRROR` + auto-detection to both the npm install scripts and Rust self-updater. + Users in China can set `CODEWHALE_USE_CNB_MIRROR=1` to download + binaries from cnb.cool instead of GitHub Releases (#2222). +- **State-root migration continues.** Migrated these storage paths to + prefer `~/.codewhale` with `~/.deepseek` fallback: snapshots, skill + state, spillover, memory, logs, crashes, automations, TUI settings, + handoff, notes, MCP config, sub-agent state, cycle archives, and + anchors. Added `resolve_project_state_dir` and `ensure_project_state_dir` + to `codewhale-config` for project-local resolution (#2231). - **READMEs updated for the CodeWhale rename.** All three READMEs now reference canonical `~/.codewhale` paths for config, skills, and Docker volumes, with legacy `~/.deepseek` noted as a compatibility fallback. From 236ad4137d88bb8a58a5c1341f030f4352645265 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 14:34:21 -0500 Subject: [PATCH 093/283] feat: harvest 6 community PRs for v0.8.47 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harvested and vetted — no malware, no external deps, no injection: - #1859 (@harvey2011888): loop guard now reports Failed on halt - #1870 (@victorcheng2333): honour DEEPSEEK_YOLO env on startup - #1935 (@IIzzaya): replace [x] with [✓] completion markers - #1837 (@PurplePulse): fix macOS title centering (pin to top) - #1967 (@cyq1017): show base_url in /config view - #1906 (@knqiufan): copy transcript without visual-wrap newlines Also fix cycle_manager archive_dir_for to use resolve_state_dir so recall_archive tests pass with the migrated sessions path. Co-authored-by: victorcheng2333 Co-authored-by: IIzzaya Co-authored-by: PurplePulse Co-authored-by: cyq1017 Co-authored-by: knqiufan --- crates/tui/src/commands/config.rs | 180 +++++++++++++++++++++- crates/tui/src/commands/mod.rs | 8 +- crates/tui/src/commands/network.rs | 6 +- crates/tui/src/commands/skills.rs | 2 +- crates/tui/src/config_ui.rs | 6 +- crates/tui/src/core/engine/turn_loop.rs | 4 +- crates/tui/src/cycle_manager.rs | 21 +-- crates/tui/src/main.rs | 18 ++- crates/tui/src/settings.rs | 4 + crates/tui/src/tui/history.rs | 97 +++++++++++- crates/tui/src/tui/markdown_render.rs | 155 +++++++++++++------ crates/tui/src/tui/mouse_ui.rs | 45 ++++-- crates/tui/src/tui/onboarding/mod.rs | 7 +- crates/tui/src/tui/scrolling.rs | 28 ++++ crates/tui/src/tui/sidebar.rs | 20 +-- crates/tui/src/tui/transcript.rs | 33 +++- crates/tui/src/tui/ui/tests.rs | 99 ++++++++++++ crates/tui/src/tui/ui_text.rs | 18 +++ crates/tui/src/tui/views/mod.rs | 38 +++++ crates/tui/src/tui/views/status_picker.rs | 2 +- crates/tui/src/tui/widgets/mod.rs | 3 +- docs/CONFIGURATION.md | 2 +- docs/MODES.md | 2 +- 23 files changed, 691 insertions(+), 107 deletions(-) diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 40ffe1dc..3496c826 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 b1e9f3dd..f21df395 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -702,8 +702,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 563ded91..dbe0e7af 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/commands/skills.rs b/crates/tui/src/commands/skills.rs index b1823d5f..9dc7fbf7 100644 --- a/crates/tui/src/commands/skills.rs +++ b/crates/tui/src/commands/skills.rs @@ -441,7 +441,7 @@ fn sync_skills(app: &mut App) -> CommandResult { } SkillSyncOutcome::Denied { name, host } => { failed += 1; - let _ = writeln!(out, " [x] {name} — network denied ({host})"); + let _ = writeln!(out, " [✓] {name} — network denied ({host})"); } SkillSyncOutcome::NeedsApproval { name, host } => { failed += 1; diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index 7f3b5801..9cf8ecd2 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -687,7 +687,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/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 1a1a9104..0a3402b8 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -1944,7 +1944,9 @@ impl Engine { if let Some(message) = loop_guard_halt { crate::logging::warn(message.clone()); - let _ = self.tx_event.send(Event::status(message)).await; + let _ = self.tx_event.send(Event::status(message.clone())).await; + // 设置 turn_error 以确保最终返回 TurnOutcomeStatus::Failed 而非 Completed + turn_error = Some(message); break; } diff --git a/crates/tui/src/cycle_manager.rs b/crates/tui/src/cycle_manager.rs index cfbe2a17..6817811e 100644 --- a/crates/tui/src/cycle_manager.rs +++ b/crates/tui/src/cycle_manager.rs @@ -284,7 +284,7 @@ impl StructuredState { let marker = match item.status { crate::tools::todo::TodoStatus::Pending => "[ ]", crate::tools::todo::TodoStatus::InProgress => "[~]", - crate::tools::todo::TodoStatus::Completed => "[x]", + crate::tools::todo::TodoStatus::Completed => "[✓]", }; out.push_str(&format!("- {marker} {}\n", item.content)); } @@ -299,7 +299,7 @@ impl StructuredState { let marker = match item.status { crate::tools::plan::StepStatus::Pending => "[ ]", crate::tools::plan::StepStatus::InProgress => "[~]", - crate::tools::plan::StepStatus::Completed => "[x]", + crate::tools::plan::StepStatus::Completed => "[✓]", }; out.push_str(&format!("- {marker} {}\n", item.step)); } @@ -463,14 +463,17 @@ pub struct CycleArchiveHeader { pub message_count: usize, } -/// Resolve the on-disk archive directory: `~/.deepseek/sessions//cycles`. +/// Resolve the on-disk archive directory: `~/.codewhale/sessions//cycles` +/// (or legacy `~/.deepseek/sessions//cycles`). fn archive_dir_for(session_id: &str) -> Result { - let home = dirs::home_dir().context("Could not resolve home directory for cycle archive")?; - Ok(home - .join(".deepseek") - .join("sessions") - .join(session_id) - .join("cycles")) + let sessions = codewhale_config::resolve_state_dir("sessions") + .unwrap_or_else(|_| { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".deepseek") + .join("sessions") + }); + Ok(sessions.join(session_id).join("cycles")) } /// Archive a cycle's messages to JSONL on disk and return the path written. diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 473484dc..56c1a8ae 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -839,8 +839,12 @@ async fn main() -> Result<()> { std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) }); let resume_session_id = resolve_exec_resume_session_id(&args, &workspace)?; + // The `deepseek` launcher forwards `--yolo` to this binary via + // the DEEPSEEK_YOLO env var (which the config loader folds into + // `config.yolo`), not as a CLI flag. Honour either source. + let yolo = cli.yolo || config.yolo.unwrap_or(false); let needs_engine = args.auto - || cli.yolo + || yolo || resume_session_id.is_some() || args.output_format == ExecOutputFormat::StreamJson; if needs_engine { @@ -848,7 +852,7 @@ async fn main() -> Result<()> { || config.max_subagents(), |value| value.clamp(1, MAX_SUBAGENTS), ); - let auto_mode = args.auto || cli.yolo; + let auto_mode = args.auto || yolo; run_exec_agent( &config, &model, @@ -4869,6 +4873,10 @@ async fn run_interactive( let _ = manager.cleanup_old_sessions(); } + // The `deepseek` launcher forwards `--yolo` to this binary via the + // DEEPSEEK_YOLO env var (config.yolo), not as a CLI flag. Honour either. + let yolo = cli.yolo || config.yolo.unwrap_or(false); + tui::run_tui( config, tui::TuiOptions { @@ -4876,7 +4884,7 @@ async fn run_interactive( workspace, config_path: cli.config.clone(), config_profile: cli.profile.clone(), - allow_shell: cli.yolo || config.allow_shell(), + allow_shell: yolo || config.allow_shell(), use_alt_screen, use_mouse_capture, use_bracketed_paste, @@ -4885,9 +4893,9 @@ async fn run_interactive( notes_path: config.notes_path(), mcp_config_path: config.mcp_config_path(), use_memory: config.memory_enabled(), - start_in_agent_mode: cli.yolo, + start_in_agent_mode: yolo, skip_onboarding: cli.skip_onboarding, - yolo: cli.yolo, // YOLO mode auto-approves all tool executions + yolo, // YOLO mode auto-approves all tool executions resume_session_id, initial_input, max_subagents, diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 040ba88f..f4520af8 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -770,6 +770,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/history.rs b/crates/tui/src/tui/history.rs index 90a07b9e..f2ce686e 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -15,6 +15,7 @@ use crate::tools::review::ReviewOutput; use crate::tui::app::TranscriptSpacing; use crate::tui::diff_render; use crate::tui::markdown_render; +use crate::tui::ui_text::CopyLineSeparator; // === Constants === @@ -158,6 +159,12 @@ pub struct TranscriptRenderOptions { pub spacing: TranscriptSpacing, } +pub(crate) struct RenderedTranscriptLine { + pub line: Line<'static>, + pub copy_prefix_width: usize, + pub copy_separator_after: CopyLineSeparator, +} + impl Default for TranscriptRenderOptions { fn default() -> Self { Self { @@ -296,6 +303,39 @@ impl HistoryCell { } } + pub(crate) fn lines_with_copy_metadata( + &self, + width: u16, + options: TranscriptRenderOptions, + ) -> Vec { + match self { + HistoryCell::User { content } => render_message_with_copy_metadata( + USER_GLYPH, + user_label_style(), + user_body_style(), + content, + width, + ), + HistoryCell::Assistant { content, streaming } => render_message_with_copy_metadata( + ASSISTANT_GLYPH, + assistant_label_style_for(*streaming, options.low_motion), + message_body_style(), + content, + width, + ), + HistoryCell::System { content } if !is_cycle_boundary(content) => { + render_message_with_copy_metadata( + "Note", + system_label_style(), + system_body_style(), + content, + width, + ) + } + _ => hard_break_copy_lines(self.lines_with_options(width, options)), + } + } + /// Render the cell in transcript mode: full content, no caps, no /// "Alt+V for details" affordances. /// @@ -2193,6 +2233,19 @@ fn render_message( content: &str, width: u16, ) -> Vec> { + render_message_with_copy_metadata(prefix, label_style, body_style, content, width) + .into_iter() + .map(|rendered| rendered.line) + .collect() +} + +fn render_message_with_copy_metadata( + prefix: &str, + label_style: Style, + body_style: Style, + content: &str, + width: u16, +) -> Vec { let prefix_width = UnicodeWidthStr::width(prefix); let prefix_width_u16 = u16::try_from(prefix_width.saturating_add(2)).unwrap_or(u16::MAX); let content_width = usize::from(width.saturating_sub(prefix_width_u16).max(1)); @@ -2200,7 +2253,7 @@ fn render_message( let rendered = markdown_render::render_markdown_tagged(content, content_width as u16, body_style); for (idx, rendered_line) in rendered.into_iter().enumerate() { - if idx == 0 { + let line = if idx == 0 { let mut spans = Vec::new(); if !prefix.is_empty() { spans.push(Span::styled( @@ -2210,7 +2263,7 @@ fn render_message( spans.push(Span::raw(" ")); } spans.extend(rendered_line.line.spans); - lines.push(Line::from(spans)); + Line::from(spans) } else { let indent = if prefix.is_empty() { String::new() @@ -2225,15 +2278,49 @@ fn render_message( let rail_style = Style::default().fg(palette::TEXT_DIM); let mut spans = vec![Span::styled(indent, rail_style)]; spans.extend(rendered_line.line.spans); - lines.push(Line::from(spans)); - } + Line::from(spans) + }; + lines.push(RenderedTranscriptLine { + line, + copy_prefix_width: rendered_line.copy_prefix_width + + history_copy_prefix_width(prefix, prefix_width, rendered_line.is_code, idx), + copy_separator_after: rendered_line.copy_separator_after, + }); } if lines.is_empty() { - lines.push(Line::from("")); + lines.push(RenderedTranscriptLine { + line: Line::from(""), + copy_prefix_width: 0, + copy_separator_after: CopyLineSeparator::Newline, + }); } lines } +fn history_copy_prefix_width( + prefix: &str, + prefix_width: usize, + is_code: bool, + line_index: usize, +) -> usize { + if line_index > 0 && is_code && !prefix.is_empty() { + prefix_width + 1 + } else { + 0 + } +} + +fn hard_break_copy_lines(lines: Vec>) -> Vec { + lines + .into_iter() + .map(|line| RenderedTranscriptLine { + line, + copy_prefix_width: 0, + copy_separator_after: CopyLineSeparator::Newline, + }) + .collect() +} + /// Render a plain-text user message: split on newlines, word-wrap each line, /// preserve leading whitespace. No markdown interpretation (headings, lists, /// code blocks, etc. are rendered as literal text). diff --git a/crates/tui/src/tui/markdown_render.rs b/crates/tui/src/tui/markdown_render.rs index e9c92e3a..0d645510 100644 --- a/crates/tui/src/tui/markdown_render.rs +++ b/crates/tui/src/tui/markdown_render.rs @@ -33,6 +33,7 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::palette; use crate::tui::osc8; +use crate::tui::ui_text::CopyLineSeparator; // Thread-local counter incremented every time `parse` runs. Used by tests to // prove that width-only changes hit the cached-AST path and skip parsing. @@ -101,6 +102,8 @@ pub struct ParsedMarkdown { pub struct RenderedMarkdownLine { pub line: Line<'static>, pub is_code: bool, + pub copy_prefix_width: usize, + pub copy_separator_after: CopyLineSeparator, } /// Parse markdown source into a width-independent block AST. @@ -227,6 +230,8 @@ pub fn render_parsed_tagged( .map(|line| RenderedMarkdownLine { line, is_code: false, + copy_prefix_width: 0, + copy_separator_after: CopyLineSeparator::Newline, }), ); continue; @@ -246,6 +251,8 @@ pub fn render_parsed_tagged( Style::default().fg(palette::TEXT_DIM), )), is_code: false, + copy_prefix_width: 0, + copy_separator_after: CopyLineSeparator::Newline, }); } Block::HorizontalRule => { @@ -255,18 +262,19 @@ pub fn render_parsed_tagged( Style::default().fg(palette::TEXT_DIM), )), is_code: false, + copy_prefix_width: 0, + copy_separator_after: CopyLineSeparator::Newline, }); } Block::ListItem { bullet, text } => { let bullet_style = Style::default().fg(palette::DEEPSEEK_SKY); - out.extend( - render_list_line(bullet, text, width, bullet_style, base_style) - .into_iter() - .map(|line| RenderedMarkdownLine { - line, - is_code: false, - }), - ); + out.extend(render_list_line_tagged( + bullet, + text, + width, + bullet_style, + base_style, + )); } Block::Code { line } => { let code_style = Style::default() @@ -280,19 +288,16 @@ pub fn render_parsed_tagged( let link_style = Style::default() .fg(palette::DEEPSEEK_BLUE) .add_modifier(Modifier::UNDERLINED); - out.extend( - render_line_with_links(text, width, base_style, link_style) - .into_iter() - .map(|line| RenderedMarkdownLine { - line, - is_code: false, - }), - ); + out.extend(render_line_with_links_tagged( + text, width, base_style, link_style, + )); } Block::Blank => { out.push(RenderedMarkdownLine { line: Line::from(""), is_code: false, + copy_prefix_width: 0, + copy_separator_after: CopyLineSeparator::Newline, }); } Block::TableRow(_) | Block::TableSeparator => unreachable!(), @@ -304,6 +309,8 @@ pub fn render_parsed_tagged( out.push(RenderedMarkdownLine { line: Line::from(""), is_code: false, + copy_prefix_width: 0, + copy_separator_after: CopyLineSeparator::Newline, }); } @@ -484,6 +491,7 @@ fn render_wrapped_line_tagged( }; let mut out = Vec::new(); + let last_index = wrapped.len().saturating_sub(1); for (idx, chunk) in wrapped.into_iter().enumerate() { let line = if idx == 0 { Line::from(vec![Span::raw(prefix), Span::styled(chunk, style)]) @@ -493,47 +501,87 @@ fn render_wrapped_line_tagged( Span::styled(chunk, style), ]) }; - out.push(RenderedMarkdownLine { line, is_code }); + let copy_separator_after = if idx == last_index { + CopyLineSeparator::Newline + } else if is_code { + CopyLineSeparator::None + } else { + CopyLineSeparator::Space + }; + out.push(RenderedMarkdownLine { + line, + is_code, + copy_prefix_width: if indent_code { prefix_width } else { 0 }, + copy_separator_after, + }); } out } -fn render_list_line( +fn render_list_line_tagged( bullet: &str, text: &str, width: usize, bullet_style: Style, text_style: Style, -) -> Vec> { +) -> Vec { let bullet_prefix = format!("{bullet} "); let bullet_width = bullet_prefix.width(); let available = width.saturating_sub(bullet_width).max(1); - let wrapped = render_line_with_links(text, available, text_style, link_style()); + let wrapped = render_line_with_links_tagged(text, available, text_style, link_style()); let mut out = Vec::new(); - for (idx, line) in wrapped.into_iter().enumerate() { + for (idx, rendered) in wrapped.into_iter().enumerate() { if idx == 0 { let mut spans = vec![Span::styled(bullet_prefix.clone(), bullet_style)]; - spans.extend(line.spans); - out.push(Line::from(spans)); + spans.extend(rendered.line.spans); + out.push(RenderedMarkdownLine { + line: Line::from(spans), + is_code: false, + copy_prefix_width: 0, + copy_separator_after: rendered.copy_separator_after, + }); } else { let mut spans = vec![Span::raw(" ".repeat(bullet_width))]; - spans.extend(line.spans); - out.push(Line::from(spans)); + spans.extend(rendered.line.spans); + out.push(RenderedMarkdownLine { + line: Line::from(spans), + is_code: false, + copy_prefix_width: bullet_width, + copy_separator_after: rendered.copy_separator_after, + }); } } out } +#[cfg(test)] fn render_line_with_links( line: &str, width: usize, base_style: Style, link_style: Style, ) -> Vec> { + render_line_with_links_tagged(line, width, base_style, link_style) + .into_iter() + .map(|rendered| rendered.line) + .collect() +} + +fn render_line_with_links_tagged( + line: &str, + width: usize, + base_style: Style, + link_style: Style, +) -> Vec { if line.trim().is_empty() { - return vec![Line::from("")]; + return vec![RenderedMarkdownLine { + line: Line::from(""), + is_code: false, + copy_prefix_width: 0, + copy_separator_after: CopyLineSeparator::Newline, + }]; } // Flatten inline tokens into (word, style) pairs preserving inter-token spaces. @@ -558,8 +606,8 @@ fn render_line_with_links( } } - let mut lines = Vec::new(); - let mut current_spans: Vec = Vec::new(); + let mut lines: Vec = Vec::new(); + let mut current_spans: Vec> = Vec::new(); let mut current_width = 0usize; for word in words { @@ -581,12 +629,7 @@ fn render_line_with_links( if ww > width && width > 0 { // Flush the in-progress line first. if !current_spans.is_empty() { - if let Some(last) = current_spans.last() - && last.content.as_ref() == " " - { - current_spans.pop(); - } - lines.push(Line::from(std::mem::take(&mut current_spans))); + push_inline_line(&mut lines, &mut current_spans, CopyLineSeparator::Space); current_width = 0; } // Char-break the word into width-sized chunks. Each full chunk @@ -597,7 +640,12 @@ fn render_line_with_links( for ch in word.text.chars() { let cw = ch.width().unwrap_or(1); if chunk_w + cw > width && chunk_w > 0 { - lines.push(Line::from(vec![word.span_for(std::mem::take(&mut chunk))])); + lines.push(RenderedMarkdownLine { + line: Line::from(vec![word.span_for(std::mem::take(&mut chunk))]), + is_code: false, + copy_prefix_width: 0, + copy_separator_after: CopyLineSeparator::None, + }); chunk_w = 0; } chunk.push(ch); @@ -612,13 +660,7 @@ fn render_line_with_links( // Wrap before this word if it doesn't fit. if current_width > 0 && current_width + ww > width { // Trim trailing space span before breaking. - if let Some(last) = current_spans.last() - && last.content.as_ref() == " " - { - current_spans.pop(); - } - lines.push(Line::from(current_spans)); - current_spans = Vec::new(); + push_inline_line(&mut lines, &mut current_spans, CopyLineSeparator::Space); current_width = 0; } current_spans.push(word.into_span()); @@ -626,14 +668,39 @@ fn render_line_with_links( } if !current_spans.is_empty() { - lines.push(Line::from(current_spans)); + push_inline_line(&mut lines, &mut current_spans, CopyLineSeparator::Newline); + } else if let Some(last) = lines.last_mut() { + last.copy_separator_after = CopyLineSeparator::Newline; } if lines.is_empty() { - lines.push(Line::from("")); + lines.push(RenderedMarkdownLine { + line: Line::from(""), + is_code: false, + copy_prefix_width: 0, + copy_separator_after: CopyLineSeparator::Newline, + }); } lines } +fn push_inline_line( + lines: &mut Vec, + spans: &mut Vec>, + copy_separator_after: CopyLineSeparator, +) { + if let Some(last) = spans.last() + && last.content.as_ref() == " " + { + spans.pop(); + } + lines.push(RenderedMarkdownLine { + line: Line::from(std::mem::take(spans)), + is_code: false, + copy_prefix_width: 0, + copy_separator_after, + }); +} + #[derive(Clone)] struct InlineToken { text: String, diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index 47e323a7..92278bb6 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -744,9 +744,14 @@ pub(crate) fn selection_to_text(app: &App) -> Option { let end_index = end.line_index.min(lines.len().saturating_sub(1)); let start_index = start.line_index.min(end_index); - let mut selected_lines = Vec::new(); + let line_meta = app.viewport.transcript_cache.line_meta(); + let mut selected = String::new(); + let mut separator_before = None; #[allow(clippy::needless_range_loop)] for line_index in start_index..=end_index { + if let Some(separator) = separator_before { + selected.push_str(separator); + } // Rail-prefix decorations are stored as cache metadata rather than // detected from glyphs, so new decoration types are covered without // changes to the copy path (#1163). @@ -755,30 +760,50 @@ pub(crate) fn selection_to_text(app: &App) -> Option { // slice off the rail prefix so subsequent column offsets operate // on content-only text. let full_text = line_to_plain(&lines[line_index]); - let line_text = if rail_width > 0 { + let line_after_rail = if rail_width > 0 { slice_text(&full_text, rail_width, text_display_width(&full_text)) } else { full_text }; + let line_after_rail_width = text_display_width(&line_after_rail); + let copy_prefix_width = line_meta + .get(line_index) + .map(|meta| meta.copy_prefix_width()) + .unwrap_or(0) + .min(line_after_rail_width); + let line_text = if copy_prefix_width > 0 { + slice_text(&line_after_rail, copy_prefix_width, line_after_rail_width) + } else { + line_after_rail + }; let line_width = text_display_width(&line_text); + let visual_prefix_width = rail_width.saturating_add(copy_prefix_width); // Selection coordinates are recorded in rendered-column space, which - // includes the visual rail prefix. Add rail_width back so the column - // window maps correctly into the rail-stripped text. + // includes visual prefixes. Add them back so the column window maps + // correctly into copy-only text. let (raw_col_start, raw_col_end) = if start_index == end_index { (start.column, end.column) } else if line_index == start_index { - (start.column, line_width.saturating_add(rail_width)) + (start.column, line_width.saturating_add(visual_prefix_width)) } else if line_index == end_index { (0, end.column) } else { - (0, line_width.saturating_add(rail_width)) + (0, line_width.saturating_add(visual_prefix_width)) }; - let col_start = raw_col_start.saturating_sub(rail_width).min(line_width); - let col_end = raw_col_end.saturating_sub(rail_width).min(line_width); + let col_start = raw_col_start + .saturating_sub(visual_prefix_width) + .min(line_width); + let col_end = raw_col_end + .saturating_sub(visual_prefix_width) + .min(line_width); let slice = slice_text(&line_text, col_start, col_end); - selected_lines.push(slice); + selected.push_str(&slice); + separator_before = line_meta + .get(line_index) + .map(|meta| meta.copy_separator_after().as_str()) + .or(Some("\n")); } - Some(selected_lines.join("\n")) + Some(selected) } diff --git a/crates/tui/src/tui/onboarding/mod.rs b/crates/tui/src/tui/onboarding/mod.rs index 4c7741d5..fa56e868 100644 --- a/crates/tui/src/tui/onboarding/mod.rs +++ b/crates/tui/src/tui/onboarding/mod.rs @@ -22,11 +22,12 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { let block = Block::default().style(Style::default().bg(palette::DEEPSEEK_INK)); f.render_widget(block, area); + const TOP_MARGIN: u16 = 2; let content_width = 76.min(area.width.saturating_sub(4)); - let content_height = 20.min(area.height.saturating_sub(4)); + let content_height = 20.min(area.height.saturating_sub(TOP_MARGIN + 2)); let content_area = Rect { - x: (area.width - content_width) / 2, - y: (area.height - content_height) / 2, + x: (area.width.saturating_sub(content_width)) / 2, + y: TOP_MARGIN, width: content_width, height: content_height, }; diff --git a/crates/tui/src/tui/scrolling.rs b/crates/tui/src/tui/scrolling.rs index 6bc51781..1e976b64 100644 --- a/crates/tui/src/tui/scrolling.rs +++ b/crates/tui/src/tui/scrolling.rs @@ -17,6 +17,8 @@ use std::time::{Duration, Instant}; +use crate::tui::ui_text::CopyLineSeparator; + const TRACKPAD_EVENT_WINDOW: Duration = Duration::from_millis(35); const WHEEL_LINES_PER_TICK: i32 = 3; const TRACKPAD_BASE_LINES_PER_TICK: i32 = 1; @@ -36,6 +38,8 @@ pub enum TranscriptLineMeta { CellLine { cell_index: usize, line_in_cell: usize, + copy_prefix_width: usize, + copy_separator_after: CopyLineSeparator, }, Spacer, } @@ -48,10 +52,32 @@ impl TranscriptLineMeta { TranscriptLineMeta::CellLine { cell_index, line_in_cell, + .. } => Some((cell_index, line_in_cell)), TranscriptLineMeta::Spacer => None, } } + + #[must_use] + pub fn copy_separator_after(&self) -> CopyLineSeparator { + match *self { + TranscriptLineMeta::CellLine { + copy_separator_after, + .. + } => copy_separator_after, + TranscriptLineMeta::Spacer => CopyLineSeparator::Newline, + } + } + + #[must_use] + pub fn copy_prefix_width(&self) -> usize { + match *self { + TranscriptLineMeta::CellLine { + copy_prefix_width, .. + } => copy_prefix_width, + TranscriptLineMeta::Spacer => 0, + } + } } // === Transcript Scroll State === @@ -271,6 +297,8 @@ mod tests { TranscriptLineMeta::CellLine { cell_index, line_in_cell, + copy_prefix_width: 0, + copy_separator_after: CopyLineSeparator::Newline, } } diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index ec3d5bad..2ebd58dd 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -442,7 +442,7 @@ fn push_work_checklist_lines( let (prefix, color) = match item.status { TodoStatus::Pending => ("[ ]", palette::TEXT_MUTED), TodoStatus::InProgress => ("[~]", palette::STATUS_WARNING), - TodoStatus::Completed => ("[x]", palette::STATUS_SUCCESS), + TodoStatus::Completed => ("[✓]", palette::STATUS_SUCCESS), }; let text = format!("{prefix} #{} {}", item.id, item.content); lines.push(Line::from(Span::styled( @@ -533,7 +533,7 @@ fn push_work_strategy_lines( let (prefix, color) = match step.status { StepStatus::Pending => ("[ ]", theme.plan_pending_color), StepStatus::InProgress => ("[~]", theme.plan_in_progress_color), - StepStatus::Completed => ("[x]", theme.plan_completed_color), + StepStatus::Completed => ("[✓]", theme.plan_completed_color), }; let mut text = format!("{prefix} {}", step.text); if !step.elapsed.is_empty() { @@ -1361,7 +1361,7 @@ fn first_nonempty_line(text: &str) -> &str { fn tool_status_marker(status: ToolStatus) -> (&'static str, ratatui::style::Color) { match status { ToolStatus::Running => ("[~]", palette::STATUS_WARNING), - ToolStatus::Success => ("[x]", palette::STATUS_SUCCESS), + ToolStatus::Success => ("[✓]", palette::STATUS_SUCCESS), ToolStatus::Failed => ("[!]", palette::STATUS_ERROR), } } @@ -1656,7 +1656,7 @@ pub fn subagent_panel_lines( fn agent_status_marker(status: &str) -> (&'static str, ratatui::style::Color) { match status { "running" => ("[~]", palette::STATUS_WARNING), - "done" => ("[x]", palette::STATUS_SUCCESS), + "done" => ("[✓]", palette::STATUS_SUCCESS), "failed" => ("[!]", palette::STATUS_ERROR), "canceled" | "interrupted" => ("[-]", palette::TEXT_MUTED), _ => ("[ ]", palette::TEXT_MUTED), @@ -2152,7 +2152,7 @@ mod tests { "recent section missing: {text:?}" ); assert!( - text.iter().any(|line| line.contains("[x] read_file")), + text.iter().any(|line| line.contains("[✓] read_file")), "recent read_file row missing: {text:?}" ); } @@ -2181,7 +2181,7 @@ mod tests { let text = lines_to_text(&task_panel_lines(&app, 64, 8)); assert!( - !text.iter().any(|line| line.contains("[x] read_file")), + !text.iter().any(|line| line.contains("[✓] read_file")), "expired completed active row should leave the sidebar: {text:?}" ); } @@ -2219,7 +2219,7 @@ mod tests { let text = lines_to_text(&task_panel_lines(&app, 64, 8)); assert!( - text.iter().any(|line| line.contains("[x] read_file")), + text.iter().any(|line| line.contains("[✓] read_file")), "fresh completed active row should linger briefly: {text:?}" ); } @@ -2372,7 +2372,7 @@ mod tests { .expect("failed grep row should stay visible"); let read_group_index = text .iter() - .position(|line| line.contains("[x] read_file x3")) + .position(|line| line.contains("[✓] read_file x3")) .expect("repeated read_file rows should collapse"); assert!( @@ -2381,7 +2381,7 @@ mod tests { ); assert_eq!( text.iter() - .filter(|line| line.contains("[x] read_file")) + .filter(|line| line.contains("[✓] read_file")) .count(), 1, "read_file should render once after grouping: {text:?}" @@ -2481,7 +2481,7 @@ mod tests { assert!( text.iter() - .any(|line| line.contains("[x] cargo check 1.2s")), + .any(|line| line.contains("[✓] cargo check 1.2s")), "status marker and duration should stay in the row label: {text:?}" ); assert!( diff --git a/crates/tui/src/tui/transcript.rs b/crates/tui/src/tui/transcript.rs index 9616a9c7..53319c96 100644 --- a/crates/tui/src/tui/transcript.rs +++ b/crates/tui/src/tui/transcript.rs @@ -26,6 +26,7 @@ use ratatui::{ use crate::tui::app::TranscriptSpacing; use crate::tui::history::{HistoryCell, TranscriptRenderOptions}; use crate::tui::scrolling::TranscriptLineMeta; +use crate::tui::ui_text::CopyLineSeparator; /// Per-cell cached render output. Reused across `ensure` calls when the /// upstream cell's revision counter hasn't changed. @@ -45,6 +46,12 @@ struct CachedCell { /// Rendered lines for this cell (without trailing inter-cell spacers), /// shared via `Arc` so cache enumeration is O(N) not O(N*lines). lines: Arc>>, + /// Copy separators aligned with `lines`. These preserve source hard + /// newlines while allowing copy to remove visual soft-wrap breaks. + copy_separators: Arc>, + /// Display-column widths of visual prefixes that should be omitted from + /// clipboard text, aligned with `lines`. + copy_prefix_widths: Arc>, /// Whether this cell's rendered output was empty (e.g. Thinking hidden). /// Cached so we can skip empty cells without re-rendering. is_empty: bool, @@ -183,11 +190,21 @@ impl TranscriptViewCache { } else { width }; - let rendered = cell.lines_with_options(render_width, options); - let is_empty = rendered.is_empty(); + let rendered = cell.lines_with_copy_metadata(render_width, options); + let mut lines = Vec::with_capacity(rendered.len()); + let mut copy_separators = Vec::with_capacity(rendered.len()); + let mut copy_prefix_widths = Vec::with_capacity(rendered.len()); + for rendered_line in rendered { + lines.push(rendered_line.line); + copy_prefix_widths.push(rendered_line.copy_prefix_width); + copy_separators.push(rendered_line.copy_separator_after); + } + let is_empty = lines.is_empty(); new_per_cell.push(CachedCell { revision: current_rev, - lines: Arc::new(rendered), + lines: Arc::new(lines), + copy_separators: Arc::new(copy_separators), + copy_prefix_widths: Arc::new(copy_prefix_widths), is_empty, is_stream_continuation: cell.is_stream_continuation(), is_conversational: cell.is_conversational(), @@ -280,6 +297,16 @@ impl TranscriptViewCache { self.line_meta.push(TranscriptLineMeta::CellLine { cell_index, line_in_cell, + copy_prefix_width: cached + .copy_prefix_widths + .get(line_in_cell) + .copied() + .unwrap_or(0), + copy_separator_after: cached + .copy_separators + .get(line_in_cell) + .copied() + .unwrap_or(CopyLineSeparator::Newline), }); } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index c73a6a6e..73a3f219 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -294,6 +294,21 @@ fn word_cursor_modifier_accepts_control_and_alt() { assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT)); } +fn select_full_transcript(app: &mut App) { + app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint { + line_index: 0, + column: 0, + }); + app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint { + line_index: app + .viewport + .transcript_cache + .total_lines() + .saturating_sub(1), + column: 80, + }); +} + #[test] fn selection_point_from_position_ignores_top_padding() { let area = Rect { @@ -375,6 +390,90 @@ fn selection_to_text_handles_multiline_and_reversed_endpoints() { assert_eq!(selection_to_text(&app).as_deref(), Some("a beta\ngam")); } +#[test] +fn selection_to_text_removes_visual_wrap_breaks_from_paragraphs() { + let mut app = create_test_app(); + app.history = vec![HistoryCell::Assistant { + content: "alpha beta gamma delta epsilon".to_string(), + streaming: false, + }]; + app.resync_history_revisions(); + app.viewport.transcript_cache.ensure( + &app.history, + &app.history_revisions, + 14, + app.transcript_render_options(), + ); + select_full_transcript(&mut app); + + let selected = selection_to_text(&app).expect("selection text"); + assert!( + !selected.contains('\n'), + "soft-wrapped paragraph copied with visual newlines: {selected:?}" + ); + assert!(selected.contains("alpha beta gamma delta epsilon")); +} + +#[test] +fn selection_to_text_preserves_wrapped_long_words() { + let mut app = create_test_app(); + app.history = vec![HistoryCell::Assistant { + content: "abcdefghijklmnop".to_string(), + streaming: false, + }]; + app.resync_history_revisions(); + app.viewport.transcript_cache.ensure( + &app.history, + &app.history_revisions, + 10, + app.transcript_render_options(), + ); + select_full_transcript(&mut app); + + let selected = selection_to_text(&app).expect("selection text"); + assert_eq!(selected, "abcdefghijklmnop"); +} + +#[test] +fn selection_to_text_strips_code_block_visual_wrap_prefixes() { + let mut app = create_test_app(); + app.history = vec![HistoryCell::Assistant { + content: "```\nlet example = abcdefghijklmnop;\n```".to_string(), + streaming: false, + }]; + app.resync_history_revisions(); + app.viewport.transcript_cache.ensure( + &app.history, + &app.history_revisions, + 14, + app.transcript_render_options(), + ); + select_full_transcript(&mut app); + + let selected = selection_to_text(&app).expect("selection text"); + assert_eq!(selected, "let example = abcdefghijklmnop;"); +} + +#[test] +fn selection_to_text_strips_list_continuation_prefixes() { + let mut app = create_test_app(); + app.history = vec![HistoryCell::Assistant { + content: "- alpha beta gamma delta epsilon".to_string(), + streaming: false, + }]; + app.resync_history_revisions(); + app.viewport.transcript_cache.ensure( + &app.history, + &app.history_revisions, + 14, + app.transcript_render_options(), + ); + select_full_transcript(&mut app); + + let selected = selection_to_text(&app).expect("selection text"); + assert_eq!(selected, "- alpha beta gamma delta epsilon"); +} + #[test] fn selection_to_text_copies_rendered_transcript_block() { let mut app = create_test_app(); diff --git a/crates/tui/src/tui/ui_text.rs b/crates/tui/src/tui/ui_text.rs index daafddb5..6c01743d 100644 --- a/crates/tui/src/tui/ui_text.rs +++ b/crates/tui/src/tui/ui_text.rs @@ -6,6 +6,24 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::tui::history::HistoryCell; use crate::tui::osc8; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CopyLineSeparator { + None, + Space, + Newline, +} + +impl CopyLineSeparator { + #[must_use] + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::None => "", + Self::Space => " ", + Self::Newline => "\n", + } + } +} + pub(crate) fn truncate_line_to_width(text: &str, max_width: usize) -> String { if max_width == 0 { return String::new(); diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index beb044be..68ce1ac7 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; @@ -614,6 +615,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(), @@ -2013,6 +2023,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 { @@ -2175,6 +2186,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")); @@ -2193,6 +2205,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(); diff --git a/crates/tui/src/tui/views/status_picker.rs b/crates/tui/src/tui/views/status_picker.rs index 2cbf576e..17b6173a 100644 --- a/crates/tui/src/tui/views/status_picker.rs +++ b/crates/tui/src/tui/views/status_picker.rs @@ -204,7 +204,7 @@ impl ModalView for StatusPickerView { for (idx, item) in self.rows.iter().enumerate() { let checked = *self.selected.get(idx).unwrap_or(&false); let is_cursor = idx == self.cursor; - let mark = if checked { "[x]" } else { "[ ]" }; + let mark = if checked { "[✓]" } else { "[ ]" }; let row_style = if is_cursor { Style::default() diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index d78c1a96..58cdffb7 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -1948,7 +1948,8 @@ fn build_empty_state_lines(app: &App, area: Rect) -> Vec> { )), ]; - let top_padding = usize::from(area.height.saturating_sub(body.len() as u16) / 3); + // Keep the welcome block near the top of the chat pane (header is separate). + let top_padding = 2usize; let mut lines = Vec::new(); for _ in 0..top_padding { lines.push(Line::from("")); diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index b92cd8ff..5d6bef88 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -540,7 +540,7 @@ If you are upgrading from older releases: `false`. When `true`, the notification body includes the elapsed duration and the turn's cost in the configured display currency. - `tui.alternate_screen` (string, optional): `auto`, `always`, or `never`. This is retained for config compatibility, but interactive sessions now always use the TUI-owned alternate screen so host terminal scrollback cannot hijack the viewport. -- `tui.mouse_capture` (bool, optional, default `true` on non-Windows terminals and on Windows Terminal/ConEmu/Cmder when the alternate screen is active; `false` on legacy Windows console and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where mouse-event escapes leak into the input stream as garbled text, see #878 / #898): enable internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. TUI-owned drag selection copies only transcript text and keeps selection scoped to the transcript pane. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection; set it to `true` or run with `--mouse-capture` to opt in anywhere it's defaulted off. On raw terminal selection, especially on legacy Windows console or when mouse capture is disabled, selection may cross the right sidebar because the terminal, not the TUI, owns the selection. +- `tui.mouse_capture` (bool, optional, default `true` on non-Windows terminals and on Windows Terminal/ConEmu/Cmder when the alternate screen is active; `false` on legacy Windows console and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where mouse-event escapes leak into the input stream as garbled text, see #878 / #898): enable internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. TUI-owned drag selection copies only transcript text, removes visual wrap-column line breaks from paragraphs, and keeps selection scoped to the transcript pane. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection; set it to `true` or run with `--mouse-capture` to opt in anywhere it's defaulted off. On raw terminal selection, especially on legacy Windows console or when mouse capture is disabled, selection may cross the right sidebar and include visual wraps because the terminal, not the TUI, owns the selection. - `tui.terminal_probe_timeout_ms` (int, optional, default `500`): startup terminal-mode probe timeout in milliseconds. Values are clamped to `100..=5000`; timeout emits a warning and aborts startup instead of hanging indefinitely. - `tui.osc8_links` (bool, optional, default `true`): emit OSC 8 escape sequences around URLs in transcript output so terminals that support them (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) render them as Cmd+click hyperlinks. Terminals without OSC 8 support render the plain URL and ignore the escape. Set `false` for terminals that misrender the sequence; selection/clipboard output always strips the escapes. - `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`). diff --git a/docs/MODES.md b/docs/MODES.md index 3da4f5b4..250721db 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -102,7 +102,7 @@ Run `codewhale --help` for the canonical list. Common flags: - `-r, --resume `: resume a saved session - `-c, --continue`: resume the most recent session in this workspace - `--max-subagents `: clamp to `1..=20` -- `--mouse-capture` / `--no-mouse-capture`: opt in or out of internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. Mouse capture is enabled by default on non-Windows terminals and on Windows Terminal/ConEmu/Cmder so drag selection copies only transcript text and stays scoped to the transcript pane; hold Shift while dragging or use `--no-mouse-capture` for raw terminal selection. It defaults off on legacy Windows console (CMD without `WT_SESSION` / `ConEmuPID`) and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where the terminal advertises mouse support but forwards SGR mouse events as raw text (#878, #898). Use `--mouse-capture` to opt in anywhere it's defaulted off. Raw terminal selection may cross the right sidebar because the terminal, not the TUI, owns the selection. +- `--mouse-capture` / `--no-mouse-capture`: opt in or out of internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. Mouse capture is enabled by default on non-Windows terminals and on Windows Terminal/ConEmu/Cmder so drag selection copies only transcript text, removes visual wrap-column line breaks from paragraphs, and stays scoped to the transcript pane; hold Shift while dragging or use `--no-mouse-capture` for raw terminal selection. It defaults off on legacy Windows console (CMD without `WT_SESSION` / `ConEmuPID`) and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where the terminal advertises mouse support but forwards SGR mouse events as raw text (#878, #898). Use `--mouse-capture` to opt in anywhere it's defaulted off. Raw terminal selection may cross the right sidebar and include visual wraps because the terminal, not the TUI, owns the selection. - `--profile `: select config profile - `--config `: config file path - `-v, --verbose`: verbose logging From 799d9f8176f3361bb914124cf206e7b99665381e Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 14:35:57 -0500 Subject: [PATCH 094/283] docs: consolidate CHANGELOG for v0.8.47, add 5 new contributors --- CHANGELOG.md | 60 +++++++++++++++++++++++++++------------------------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5616eaa6..924e4f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,49 +11,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Composer text selection with copy/cut.** Mouse drag and Shift+Arrow selection in the composer input box, with Ctrl+C copy and Ctrl+X cut - support. Home, End, Ctrl+A, and Ctrl+E now clear the selection to prevent - accidental deletions on the next keystroke (#2228). + support. Home, End, Ctrl+A, and Ctrl+E now clear the selection (#2228). +- **Copy transcript without visual-wrap newlines.** Transcript copy now + strips visual-wrap column line breaks from paragraphs, producing clean + text for pasting into editors or prompts (#1906). +- **Configurable base URL in /config view.** The `/config` panel now + displays the effective DeepSeek base URL (#1967). +- **CNB mirror support for China-friendly downloads.** Added + `CODEWHALE_RELEASE_BASE_URL` and `CODEWHALE_USE_CNB_MIRROR` to + both npm install scripts and Rust self-updater (#2222). +- **[✓] completion markers.** Checklist, plan, and tool completion + markers now render as `[✓]` instead of `[x]` (#1935). ### Changed -- **Project context loading now logs the source file.** A tracing info - line is emitted when AGENTS.md, CLAUDE.md, or another context file is - successfully loaded into the system prompt, making it easier to verify - which file was used during prompt assembly (#2227). -- **CNB mirror support for China-friendly downloads.** Added - `CODEWHALE_RELEASE_BASE_URL` env var and `CODEWHALE_USE_CNB_MIRROR` - auto-detection to both the npm install scripts and Rust self-updater. - Users in China can set `CODEWHALE_USE_CNB_MIRROR=1` to download - binaries from cnb.cool instead of GitHub Releases (#2222). -- **State-root migration continues.** Migrated these storage paths to - prefer `~/.codewhale` with `~/.deepseek` fallback: snapshots, skill - state, spillover, memory, logs, crashes, automations, TUI settings, - handoff, notes, MCP config, sub-agent state, cycle archives, and - anchors. Added `resolve_project_state_dir` and `ensure_project_state_dir` - to `codewhale-config` for project-local resolution (#2231). +- **Project context loading now logs the source file.** (#2227) +- **macOS onboarding and empty-state layout pinned to top** instead + of vertically centered (#1837). +- **State-root migration continues.** Migrated 15+ storage paths to + prefer `~/.codewhale` with `~/.deepseek` fallback (#2231). - **READMEs updated for the CodeWhale rename.** All three READMEs now - reference canonical `~/.codewhale` paths for config, skills, and Docker - volumes, with legacy `~/.deepseek` noted as a compatibility fallback. + reference canonical `~/.codewhale` paths. ### Fixed - **Deadlock when spawning multiple concurrent sub-agents.** Replaced - `RwLock`-based serialisation with a `Semaphore(1)` in `ToolCallRuntime`, - preventing re-entrant tool calls from deadlocking on the same lock (#1856). + `RwLock`-based serialisation with a `Semaphore(1)` (#1856). - **Steered/queued messages now render in correct transcript order.** - `steer_user_message` now flushes the active cell into history before - inserting the steer message, so the user's message appears after - (below) the thinking content that chronologically preceded it (#2225). -- **Session save test updated for managed sessions directory.** The - `/save` command now writes to `~/.codewhale/sessions` (or legacy - `~/.deepseek/sessions`) instead of the workspace root. Test updated - to set `CODEWHALE_HOME` and pre-create the sessions directory (#2223). + `steer_user_message` now flushes the active cell before inserting (#2225). +- **Session save test updated for managed sessions directory.** (#2223). +- **Loop guard reports Failed on halt.** Turn outcome correctly reports + `Failed` instead of `Completed` when the loop guard trips (#1859). +- **DEEPSEEK_YOLO env honoured on startup.** The `--yolo` flag is now + correctly merged with the `DEEPSEEK_YOLO` environment variable (#1870). ### Community Thanks to contributors whose PRs landed in this release: **@Fire-dtx** (#1856), -**@imkingjh999** (#2228). +**@imkingjh999** (#2228), +**@harvey2011888** (#1859), +**@victorcheng2333** (#1870), +**@IIzzaya** (#1935), +**@PurplePulse** (#1837), +**@cyq1017** (#1967), +**@knqiufan** (#1906). ## [0.8.46] - 2026-05-26 From 8822e9873a621198fee844459d97131697fde9bb Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 15:05:59 -0500 Subject: [PATCH 095/283] =?UTF-8?q?refactor:=20finish=20state-root=20migra?= =?UTF-8?q?tion=20=E2=80=94=20all=20runtime=20paths=20now=20.codewhale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .gitignore: add deep-swe/ and all_preds.jsonl to prevent accidental commits - config.rs: home_config_path(), managed_config, requirements, mcp, notes, memory all prefer ~/.codewhale/config.toml with .deepseek fallback - commands/config.rs: config_toml_path() prefers .codewhale - commands/anchor.rs: anchors_path prefers .codewhale/anchors.md - commands/note.rs: notes_path prefers .codewhale/notes.md - skills/install.rs: cache defaults to .codewhale/cache/skills - skills/mod.rs: global skills discovery includes .codewhale/skills - file_frecency, clipboard, onboarding, audit, task_manager: all .codewhale - project-local paths (onboarding trust) still .deepseek for compat Closes #2231. --- .gitignore | 3 +++ crates/tui/src/audit.rs | 2 +- crates/tui/src/commands/anchor.rs | 4 +++ crates/tui/src/commands/config.rs | 4 +++ crates/tui/src/commands/note.rs | 4 +++ crates/tui/src/config.rs | 38 +++++++++++++++++++++++----- crates/tui/src/palette.rs | 8 ++++-- crates/tui/src/skills/install.rs | 2 +- crates/tui/src/task_manager.rs | 4 +-- crates/tui/src/tui/clipboard.rs | 2 +- crates/tui/src/tui/file_frecency.rs | 2 +- crates/tui/src/tui/onboarding/mod.rs | 6 ++++- 12 files changed, 64 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 0668130d..50c41e5a 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,8 @@ apps/ # Maintainer-local SWE-bench scratch (instance workspaces, venvs, predictions, # Docker harness logs). Never published. .swebench/ +deep-swe/ +all_preds.jsonl # Agent handoffs and version-specific setup plans are working-state notes, not # public docs. Keep durable setup guidance in docs/runbooks instead. @@ -111,3 +113,4 @@ docs/*_PLAN.md # direnv .envrc .direnv +scripts/run_deep_swe.py diff --git a/crates/tui/src/audit.rs b/crates/tui/src/audit.rs index 60b49c63..2638131d 100644 --- a/crates/tui/src/audit.rs +++ b/crates/tui/src/audit.rs @@ -41,5 +41,5 @@ fn append_event(event: &str, details: Value) -> anyhow::Result<()> { fn default_audit_path() -> anyhow::Result { let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("home directory not found"))?; - Ok(home.join(".deepseek").join("audit.log")) + Ok(home.join(".codewhale").join("audit.log")) } diff --git a/crates/tui/src/commands/anchor.rs b/crates/tui/src/commands/anchor.rs index fb15fb33..7ba66d7a 100644 --- a/crates/tui/src/commands/anchor.rs +++ b/crates/tui/src/commands/anchor.rs @@ -47,6 +47,10 @@ pub fn anchor(app: &mut App, content: Option<&str>) -> CommandResult { } fn anchors_path(app: &App) -> std::path::PathBuf { + let primary = app.workspace.join(".codewhale").join("anchors.md"); + if primary.exists() { + return primary; + } app.workspace.join(".deepseek").join("anchors.md") } diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 3496c826..651b4d5d 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -379,6 +379,10 @@ pub(super) fn config_toml_path(config_path: Option<&Path>) -> anyhow::Result) -> CommandResult { } fn notes_path(app: &App) -> PathBuf { + let primary = app.workspace.join(".codewhale").join("notes.md"); + if primary.exists() { + return primary; + } app.workspace.join(".deepseek").join("notes.md") } diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 63fd1e80..55d46871 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -2200,7 +2200,13 @@ pub(crate) fn effective_home_dir() -> Option { } fn home_config_path() -> Option { - effective_home_dir().map(|home| home.join(".deepseek").join("config.toml")) + effective_home_dir().map(|home| { + let primary = home.join(".codewhale").join("config.toml"); + if primary.exists() { + return primary; + } + home.join(".deepseek").join("config.toml") + }) } #[must_use] @@ -2363,7 +2369,11 @@ fn default_managed_config_path() -> Option { } #[cfg(not(unix))] { - effective_home_dir().map(|home| home.join(".deepseek").join("managed_config.toml")) + effective_home_dir().map(|home| { + let primary = home.join(".codewhale").join("managed_config.toml"); + if primary.exists() { return primary; } + home.join(".deepseek").join("managed_config.toml") + }) } } @@ -2374,7 +2384,11 @@ fn default_requirements_path() -> Option { } #[cfg(not(unix))] { - effective_home_dir().map(|home| home.join(".deepseek").join("requirements.toml")) + effective_home_dir().map(|home| { + let primary = home.join(".codewhale").join("requirements.toml"); + if primary.exists() { return primary; } + home.join(".deepseek").join("requirements.toml") + }) } } @@ -2399,15 +2413,27 @@ fn default_skills_dir() -> Option { } fn default_mcp_config_path() -> Option { - effective_home_dir().map(|home| home.join(".deepseek").join("mcp.json")) + effective_home_dir().map(|home| { + let primary = home.join(".codewhale").join("mcp.json"); + if primary.exists() { return primary; } + home.join(".deepseek").join("mcp.json") + }) } fn default_notes_path() -> Option { - effective_home_dir().map(|home| home.join(".deepseek").join("notes.txt")) + effective_home_dir().map(|home| { + let primary = home.join(".codewhale").join("notes.txt"); + if primary.exists() { return primary; } + home.join(".deepseek").join("notes.txt") + }) } fn default_memory_path() -> Option { - effective_home_dir().map(|home| home.join(".deepseek").join("memory.md")) + effective_home_dir().map(|home| { + let primary = home.join(".codewhale").join("memory.md"); + if primary.exists() { return primary; } + home.join(".deepseek").join("memory.md") + }) } // === Environment Overrides === diff --git a/crates/tui/src/palette.rs b/crates/tui/src/palette.rs index a521c610..b3a5a367 100644 --- a/crates/tui/src/palette.rs +++ b/crates/tui/src/palette.rs @@ -8,7 +8,7 @@ use std::process::Command; pub const WHALE_BG_RGB: (u8, u8, u8) = (10, 17, 32); // #0A1120 Deep Navy pub const WHALE_PANEL_RGB: (u8, u8, u8) = (22, 34, 56); // #162238 pub const WHALE_ELEVATED_RGB: (u8, u8, u8) = (36, 52, 78); // #24344E -pub const WHALE_SELECTION_RGB: (u8, u8, u8) = (48, 68, 100); // #304464 +pub const WHALE_SELECTION_RGB: (u8, u8, u8) = (40, 56, 84); // #283854 — darker to avoid bright pop on deep navy pub const WHALE_TEXT_BODY_RGB: (u8, u8, u8) = (246, 242, 232); // #F6F2E8 Whale Ivory pub const WHALE_TEXT_SOFT_RGB: (u8, u8, u8) = (217, 224, 234); // #D9E0EA pub const WHALE_TEXT_MUTED_RGB: (u8, u8, u8) = (169, 180, 199); // #A9B4C7 Mist Gray @@ -244,7 +244,11 @@ pub const TEXT_ACCENT: Color = Color::Rgb( WHALE_ACCENT_SECONDARY_RGB.1, WHALE_ACCENT_SECONDARY_RGB.2, ); -pub const SELECTION_TEXT: Color = Color::White; +pub const SELECTION_TEXT: Color = Color::Rgb( + WHALE_TEXT_BODY_RGB.0, + WHALE_TEXT_BODY_RGB.1, + WHALE_TEXT_BODY_RGB.2, +); // Ivory — softer than pure white pub const TEXT_SOFT: Color = Color::Rgb( WHALE_TEXT_SOFT_RGB.0, WHALE_TEXT_SOFT_RGB.1, diff --git a/crates/tui/src/skills/install.rs b/crates/tui/src/skills/install.rs index aa4550be..787b6c4a 100644 --- a/crates/tui/src/skills/install.rs +++ b/crates/tui/src/skills/install.rs @@ -52,7 +52,7 @@ use crate::network_policy::{Decision, NetworkPolicy, host_from_url}; pub fn default_cache_skills_dir() -> PathBuf { dirs::home_dir().map_or_else( || PathBuf::from("/tmp/codewhale/cache/skills"), - |p| p.join(".deepseek").join("cache").join("skills"), + |p| p.join(".codewhale").join("cache").join("skills"), ) } diff --git a/crates/tui/src/task_manager.rs b/crates/tui/src/task_manager.rs index b0d9e39e..8f927023 100644 --- a/crates/tui/src/task_manager.rs +++ b/crates/tui/src/task_manager.rs @@ -1648,9 +1648,9 @@ pub fn default_tasks_dir() -> PathBuf { return PathBuf::from(path); } if let Some(home) = dirs::home_dir() { - return home.join(".deepseek").join("tasks"); + return home.join(".codewhale").join("tasks"); } - PathBuf::from(".deepseek").join("tasks") + PathBuf::from(".codewhale").join("tasks") } /// Wait for a task to reach a terminal status (tests and API helpers). diff --git a/crates/tui/src/tui/clipboard.rs b/crates/tui/src/tui/clipboard.rs index dffadfac..bbefcac8 100644 --- a/crates/tui/src/tui/clipboard.rs +++ b/crates/tui/src/tui/clipboard.rs @@ -279,7 +279,7 @@ fn osc52_sequence(text: &str, in_tmux: bool) -> Result { /// `/clipboard-images/` if the home dir is unavailable. pub(crate) fn clipboard_images_dir(workspace: &Path) -> PathBuf { if let Some(home) = dirs::home_dir() { - return home.join(".deepseek").join("clipboard-images"); + return home.join(".codewhale").join("clipboard-images"); } workspace.join("clipboard-images") } diff --git a/crates/tui/src/tui/file_frecency.rs b/crates/tui/src/tui/file_frecency.rs index 5129d695..10b83852 100644 --- a/crates/tui/src/tui/file_frecency.rs +++ b/crates/tui/src/tui/file_frecency.rs @@ -55,7 +55,7 @@ fn store() -> &'static Mutex { } fn default_path() -> Option { - dirs::home_dir().map(|h| h.join(".deepseek").join("file-frecency.jsonl")) + dirs::home_dir().map(|h| h.join(".codewhale").join("file-frecency.jsonl")) } fn now_secs() -> u64 { diff --git a/crates/tui/src/tui/onboarding/mod.rs b/crates/tui/src/tui/onboarding/mod.rs index fa56e868..2967cab6 100644 --- a/crates/tui/src/tui/onboarding/mod.rs +++ b/crates/tui/src/tui/onboarding/mod.rs @@ -128,7 +128,11 @@ pub fn tips_lines(app: &App) -> Vec> { } pub fn default_marker_path() -> Option { - dirs::home_dir().map(|home| home.join(".deepseek").join(".onboarded")) + dirs::home_dir().map(|home| { + let primary = home.join(".codewhale").join(".onboarded"); + if primary.exists() { return primary; } + home.join(".deepseek").join(".onboarded") + }) } pub fn is_onboarded() -> bool { From 02d4605868a1e087844245623640e725db4272ab Mon Sep 17 00:00:00 2001 From: LING71671 <1739677116@qq.com> Date: Tue, 26 May 2026 15:53:53 -0500 Subject: [PATCH 096/283] fix(tui): restore auto model state on session load Harvested from PR #1885 by @LING71671. Fixes #1797. --- crates/tui/src/commands/session.rs | 31 +++++++++++++++++++++++++++--- crates/tui/src/tui/app.rs | 4 ++++ crates/tui/src/tui/ui/tests.rs | 9 ++++++++- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index bc51683d..cc82b275 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -169,7 +169,7 @@ pub fn load(app: &mut App, path: Option<&str>) -> CommandResult { app.extend_history(cells_to_add); app.mark_history_updated(); app.viewport.transcript_selection.clear(); - app.model.clone_from(&session.metadata.model); + app.set_model_selection(session.metadata.model.clone()); app.update_model_compaction_budget(); app.workspace.clone_from(&session.metadata.workspace); app.session.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX); @@ -365,8 +365,8 @@ fn line_to_string(line: ratatui::text::Line<'static>) -> String { #[cfg(test)] mod tests { use super::*; - use crate::config::Config; - use crate::tui::app::{App, TuiOptions, TurnCacheRecord}; + use crate::config::{Config, DEFAULT_TEXT_MODEL}; + use crate::tui::app::{App, ReasoningEffort, TuiOptions, TurnCacheRecord}; use std::time::Instant; use tempfile::TempDir; @@ -575,6 +575,31 @@ mod tests { assert!(matches!(result.action, Some(AppAction::SyncSession { .. }))); } + #[test] + fn load_auto_model_session_restores_auto_mode() { + let tmpdir = TempDir::new().unwrap(); + let mut saved_app = create_test_app_with_tmpdir(&tmpdir); + saved_app.set_model_selection("auto".to_string()); + saved_app.last_effective_model = Some("deepseek-v4-flash".to_string()); + saved_app.last_effective_reasoning_effort = Some(ReasoningEffort::Low); + let save_path = tmpdir.path().join("auto_model.json"); + save(&mut saved_app, Some(save_path.to_str().unwrap())); + + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.set_model_selection("deepseek-v4-flash".to_string()); + app.reasoning_effort = ReasoningEffort::High; + let result = load(&mut app, Some(save_path.to_str().unwrap())); + + assert!(!result.is_error); + assert!(app.auto_model); + assert_eq!(app.model, "auto"); + assert_eq!(app.model_selection_for_persistence(), "auto"); + assert_eq!(app.last_effective_model, None); + assert_eq!(app.last_effective_reasoning_effort, None); + assert_eq!(app.reasoning_effort, ReasoningEffort::Auto); + assert_eq!(app.effective_model_for_budget(), DEFAULT_TEXT_MODEL); + } + #[test] fn load_restores_artifact_registry() { let tmpdir = TempDir::new().unwrap(); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 2c3ec0c9..449e267e 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -4501,6 +4501,10 @@ impl App { }; self.auto_model = auto_model; self.last_effective_model = None; + self.last_effective_reasoning_effort = None; + if auto_model { + self.reasoning_effort = ReasoningEffort::Auto; + } } pub fn model_selection_for_persistence(&self) -> String { diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index c73a6a6e..a2170a9a 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::config::{ApiProvider, Config}; +use crate::config::{ApiProvider, Config, DEFAULT_TEXT_MODEL}; use crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent}; use crate::core::engine::mock_engine_handle; use crate::tui::active_cell::ActiveCell; @@ -4166,6 +4166,9 @@ fn apply_loaded_session_restores_concrete_model_mode() { fn apply_loaded_session_restores_auto_model_mode() { let mut app = create_test_app(); app.set_model_selection("deepseek-v4-pro".to_string()); + app.reasoning_effort = ReasoningEffort::High; + app.last_effective_model = Some("deepseek-v4-flash".to_string()); + app.last_effective_reasoning_effort = Some(ReasoningEffort::Low); let mut session = saved_session_with_messages(vec![ text_message("user", "hello"), text_message("assistant", "hi"), @@ -4178,6 +4181,10 @@ fn apply_loaded_session_restores_auto_model_mode() { assert!(app.auto_model); assert_eq!(app.model, "auto"); assert_eq!(app.model_selection_for_persistence(), "auto"); + assert_eq!(app.last_effective_model, None); + assert_eq!(app.last_effective_reasoning_effort, None); + assert_eq!(app.reasoning_effort, ReasoningEffort::Auto); + assert_eq!(app.effective_model_for_budget(), DEFAULT_TEXT_MODEL); } #[test] From 671aa4810eb9cb1199a1c9a9a7d703e9a714367e Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 16:37:33 -0500 Subject: [PATCH 097/283] docs: add Docker toolbox/custom-image contract and examples (#2217) - Document default image contract (non-root, no sudo, conservative) - Add opt-in toolbox image pattern with passwordless sudo - Include Dockerfile.toolbox example - Document multi-project volume pattern - Add bootstrap script, custom CA certificate, and proxy workflows - Clarify that bootstrap/CA workflows require the opt-in toolbox image --- docs/DOCKER.md | 138 +++++++++++++++++++++++++++++++ docs/examples/Dockerfile.toolbox | 29 +++++++ 2 files changed, 167 insertions(+) create mode 100644 docs/examples/Dockerfile.toolbox diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 732c4705..c4f62491 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -36,6 +36,144 @@ docker run --rm -it \ Replace `vX.Y.Z` with a tag from [GitHub Releases](https://github.com/Hmbown/CodeWhale/releases). +## Default image contract + +`ghcr.io/hmbown/codewhale:latest` and the semver tags are conservative runtime +images: + +- the container runs as the non-root `codewhale` user with UID/GID `1000:1000` +- the image does not grant passwordless `sudo` +- the image is meant to run CodeWhale against mounted workspaces, not to mutate + the base operating system at runtime +- user state belongs in a volume mounted at `/home/codewhale/.deepseek` + +That default is intentional. Keep using it for the smallest trust boundary. If a +project needs `apt-get`, compiler toolchains, Node/Python package managers, +custom CA certificates, or other host-like setup inside Docker, build an +explicit toolbox image instead of changing the default image contract. + +## Opt-in toolbox/custom image + +The repository includes an example +[`docs/examples/Dockerfile.toolbox`](examples/Dockerfile.toolbox) that extends +the official image with passwordless `sudo` and common development packages. +Build it with a pinned CodeWhale tag when you want repeatable project +environments: + +```bash +docker build -f docs/examples/Dockerfile.toolbox \ + --build-arg CODEWHALE_IMAGE=ghcr.io/hmbown/codewhale:vX.Y.Z \ + --build-arg TOOLBOX_PACKAGES="git openssh-client curl build-essential pkg-config python3 python3-pip nodejs npm" \ + -t codewhale-toolbox:my-project . +``` + +Use `latest` only for throwaway testing. For shared projects, keep the +`CODEWHALE_IMAGE` value pinned and review package additions like any other +development-environment change. + +Run the toolbox image with the same workspace and state mounts: + +```bash +docker volume create codewhale-my-project-home + +docker run --rm -it \ + -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ + -v codewhale-my-project-home:/home/codewhale/.deepseek \ + -v "$PWD:/workspace" \ + -w /workspace \ + codewhale-toolbox:my-project +``` + +Inside this opt-in image, CodeWhale can use commands such as +`sudo apt-get update` and `sudo apt-get install -y `. For repeatable +containers, prefer baking those packages into the toolbox Dockerfile instead of +letting a long-lived container drift. + +Do not bake API keys, SSH private keys, or other secrets into custom images. +Pass API keys at runtime and mount any SSH material deliberately, preferably +read-only and only for projects that need it. + +## Multiple independent projects + +Use one named state volume per project so sessions, config, skills, memory, and +the offline queue do not bleed across workspaces: + +```bash +project="$(basename "$PWD")" +image="codewhale-toolbox:${project}" +docker volume create "codewhale-${project}-home" + +docker run --rm -it \ + --name "codewhale-${project}" \ + -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ + -v "codewhale-${project}-home:/home/codewhale/.deepseek" \ + -v "$PWD:/workspace" \ + -w /workspace \ + "$image" +``` + +For projects with different toolchains, build different toolbox tags, for +example `codewhale-toolbox:frontend` and `codewhale-toolbox:backend`. The +separate launcher idea discussed in issue #2217 can build on this contract, but +it is intentionally outside the core Docker image. + +## Project bootstrap scripts + +CodeWhale does not automatically execute `.deepseek/setup.sh` or +`.codewhale/setup.sh`. If you keep one of those files as a local project +recipe, run it explicitly. For shared team setup, prefer a committed project +script or the toolbox Dockerfile so the environment can be reviewed and +rebuilt. + +For example, to run a committed bootstrap script before starting CodeWhale: + +```bash +docker run --rm -it \ + -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ + -v codewhale-my-project-home:/home/codewhale/.deepseek \ + -v "$PWD:/workspace" \ + -w /workspace \ + --entrypoint bash \ + codewhale-toolbox:my-project \ + -lc './scripts/bootstrap-dev.sh && exec codewhale' +``` + +Use the toolbox image for bootstrap scripts that need `sudo`. The default image +will not elevate privileges. + +## Custom CA certificates and proxies + +For corporate proxies, dev-sidecar, or self-signed internal services, prefer +baking trusted CA certificates into a custom toolbox image: + +```dockerfile +USER root +COPY docker/certs/*.crt /usr/local/share/ca-certificates/ +RUN update-ca-certificates +USER codewhale +``` + +All files copied into `/usr/local/share/ca-certificates/` must use the `.crt` +extension. Keep private CA material out of public images. + +For a local-only run, mount certificates read-only and update the trust store at +container start: + +```bash +docker run --rm -it \ + -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ + -v codewhale-my-project-home:/home/codewhale/.deepseek \ + -v "$PWD:/workspace" \ + -v "$PWD/docker/certs:/usr/local/share/ca-certificates/local:ro" \ + -w /workspace \ + --entrypoint bash \ + codewhale-toolbox:my-project \ + -lc 'sudo update-ca-certificates && exec codewhale' +``` + +This CA workflow requires the opt-in toolbox image because the default image +does not include passwordless `sudo`. + ## Local build Build the image locally from a checkout: diff --git a/docs/examples/Dockerfile.toolbox b/docs/examples/Dockerfile.toolbox new file mode 100644 index 00000000..fab0b73e --- /dev/null +++ b/docs/examples/Dockerfile.toolbox @@ -0,0 +1,29 @@ +# syntax=docker/dockerfile:1 +# +# Opt-in CodeWhale toolbox image. +# +# The published ghcr.io/hmbown/codewhale:latest image intentionally stays +# minimal, non-root, and without passwordless sudo. Use this Dockerfile only for +# workspaces where you deliberately want package installation, custom CA setup, +# or project-specific build tools inside the container. +# +# Example: +# docker build -f docs/examples/Dockerfile.toolbox \ +# --build-arg CODEWHALE_IMAGE=ghcr.io/hmbown/codewhale:vX.Y.Z \ +# --build-arg TOOLBOX_PACKAGES="git openssh-client curl build-essential pkg-config python3 python3-pip nodejs npm" \ +# -t codewhale-toolbox:my-project . + +ARG CODEWHALE_IMAGE=ghcr.io/hmbown/codewhale:latest +FROM ${CODEWHALE_IMAGE} + +USER root + +ARG TOOLBOX_PACKAGES="git openssh-client curl build-essential pkg-config python3 python3-pip nodejs npm" +RUN apt-get update \ + && apt-get install -y --no-install-recommends sudo ${TOOLBOX_PACKAGES} \ + && rm -rf /var/lib/apt/lists/* \ + && printf '%s\n' 'codewhale ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/codewhale-nopasswd \ + && chmod 0440 /etc/sudoers.d/codewhale-nopasswd + +USER codewhale +WORKDIR /workspace From 2b8f3bf3ddfaeff6c47dfe4cea3bc5d64bb61db5 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 16:37:33 -0500 Subject: [PATCH 098/283] docs: add provider registry reference and refresh provider lists (#2201) - Add docs/PROVIDERS.md placeholder reference in README and CONFIGURATION.md - Update provider lists to include moonshot, openrouter, novita - Add MOONSHOT_API_KEY/KIMI_API_KEY env var docs - Update default_text_model entries for all providers - Change legacy deepseek-cn alias to deepseek - Add CODEWHALE_PROVIDER as preferred env var name --- README.md | 7 +- docs/CONFIGURATION.md | 18 +++-- docs/PROVIDERS.md | 159 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 docs/PROVIDERS.md diff --git a/README.md b/README.md index 334ae74f..88e679df 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,9 @@ Both binaries are required. Cross-compilation and platform-specific notes: [docs ### Other API Providers +For the full shipped provider registry, including model IDs, auth variables, +base URLs, and capability boundaries, see [docs/PROVIDERS.md](docs/PROVIDERS.md). + ```bash # NVIDIA NIM codewhale auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY" @@ -472,11 +475,11 @@ Key environment variables: | `DEEPSEEK_HTTP_HEADERS` | Optional custom model request headers, e.g. `X-Model-Provider-Id=your-model-provider` | | `DEEPSEEK_MODEL` | Default model | | `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | Stream idle timeout in seconds, default `300`, clamped to `1..=3600` | -| `DEEPSEEK_PROVIDER` | `codewhale` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `sglang`, `vllm`, `ollama` | +| `CODEWHALE_PROVIDER` / `DEEPSEEK_PROVIDER` | `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `moonshot`, `sglang`, `vllm`, `ollama` | | `DEEPSEEK_PROFILE` | Config profile name | | `DEEPSEEK_MEMORY` | Set to `on` to enable user memory | | `DEEPSEEK_ALLOW_INSECURE_HTTP=1` | Allow non-local `http://` API base URLs on trusted networks | -| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | Provider auth | +| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `MOONSHOT_API_KEY` / `KIMI_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | Provider auth | | `OPENAI_BASE_URL` / `OPENAI_MODEL` | Generic OpenAI-compatible endpoint and model ID | | `ATLASCLOUD_BASE_URL` / `ATLASCLOUD_MODEL` | AtlasCloud endpoint and model override | | `WANJIE_ARK_BASE_URL` / `WANJIE_ARK_MODEL` | Wanjie Ark endpoint and model override | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index b92cd8ff..1c362862 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -62,8 +62,11 @@ label without printing the key itself. The command only probes the active provider's keyring entry. For hosted, generic OpenAI-compatible, or self-hosted providers, set -`provider = "nvidia-nim"`, `"openai"`, `"atlascloud"`, `"wanjie-ark"`, `"fireworks"`, -`"sglang"`, `"vllm"`, or `"ollama"` or pass `codewhale --provider `. +`provider = "nvidia-nim"`, `"openai"`, `"atlascloud"`, `"wanjie-ark"`, +`"openrouter"`, `"novita"`, `"fireworks"`, `"moonshot"`, `"sglang"`, +`"vllm"`, or `"ollama"` or pass `codewhale --provider `. +For the provider-by-provider registry, including auth variables, default base +URLs, model IDs, and capability metadata, see [PROVIDERS.md](PROVIDERS.md). The facade saves provider credentials to the shared user config and forwards the resolved key, base URL, provider, and model to the TUI process. Use `codewhale auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"` or @@ -204,7 +207,7 @@ aliases. When both forms are set the `CODEWHALE_*` value wins; the `DEEPSEEK_*` form is kept for older shells: - `CODEWHALE_PROVIDER` (preferred) / `DEEPSEEK_PROVIDER` (legacy alias) — - `deepseek|nvidia-nim|openai|atlascloud|wanjie-ark|openrouter|novita|fireworks|sglang|vllm|ollama` + `deepseek|nvidia-nim|openai|atlascloud|wanjie-ark|openrouter|novita|fireworks|moonshot|sglang|vllm|ollama` - `CODEWHALE_MODEL` (preferred) / `DEEPSEEK_MODEL` (legacy alias) — default model for the active provider - `CODEWHALE_BASE_URL` (preferred) / `DEEPSEEK_BASE_URL` (legacy alias) — base URL for the active provider @@ -233,6 +236,9 @@ Remaining variables: - `NOVITA_BASE_URL` - `FIREWORKS_API_KEY` - `FIREWORKS_BASE_URL` +- `MOONSHOT_API_KEY` or `KIMI_API_KEY` +- `MOONSHOT_BASE_URL` or `KIMI_BASE_URL` +- `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, or `KIMI_MODEL` - `SGLANG_BASE_URL` - `SGLANG_MODEL` - `SGLANG_API_KEY` (optional; many localhost SGLang servers do not require auth) @@ -435,10 +441,10 @@ If you are upgrading from older releases: ### Core keys (used by the TUI/engine) -- `provider` (string, optional): `codewhale` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `sglang`, `vllm`, or `ollama`. Legacy `deepseek-cn` configs are still accepted as an alias for `codewhale`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`. +- `provider` (string, optional): `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `moonshot`, `sglang`, `vllm`, or `ollama`. Legacy `deepseek-cn` configs are still accepted as an alias for `deepseek`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `openrouter` targets `https://openrouter.ai/api/v1`; `novita` targets `https://api.novita.ai/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `moonshot` targets Moonshot/Kimi, defaulting to `https://api.moonshot.ai/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`. - `api_key` (string, required for hosted providers): must be non-empty for DeepSeek/hosted providers (or set the provider API key env var). Self-hosted SGLang, vLLM, and Ollama can omit it. -- `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs, `https://api.openai.com/v1` for `provider = "openai"`, `https://api.atlascloud.ai/v1` for `provider = "atlascloud"`, `https://maas-openapi.wanjiedata.com/api/v1` for `provider = "wanjie-ark"`, or the provider-specific endpoint for hosted/self-hosted providers. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. -- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `codewhale-coder:1.3b` for Ollama. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. Generic `openai`, `atlascloud`, `wanjie-ark`, and Ollama model IDs are passed through unchanged. OpenRouter provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `DEEPSEEK_MODEL` overrides this for a single process. +- `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs. Other defaults are `https://integrate.api.nvidia.com/v1` for `nvidia-nim`, `https://api.openai.com/v1` for `openai`, `https://api.atlascloud.ai/v1` for `atlascloud`, `https://maas-openapi.wanjiedata.com/api/v1` for `wanjie-ark`, `https://openrouter.ai/api/v1` for `openrouter`, `https://api.novita.ai/v1` for `novita`, `https://api.fireworks.ai/inference/v1` for `fireworks`, `https://api.moonshot.ai/v1` for `moonshot`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, and `http://localhost:11434/v1` for `ollama`. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. +- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `deepseek/deepseek-v4-pro` for OpenRouter and Novita, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `kimi-k2.6` for Moonshot, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. Generic `openai`, `atlascloud`, `wanjie-ark`, and Ollama model IDs are passed through unchanged. OpenRouter provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `CODEWHALE_MODEL` overrides this for a single process; `DEEPSEEK_MODEL` is the legacy alias. - `reasoning_effort` (string, optional): `off`, `low`, `medium`, `high`, or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`. - `allow_shell` (bool, optional): defaults to `true` (sandboxed). - `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases. diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md new file mode 100644 index 00000000..38969415 --- /dev/null +++ b/docs/PROVIDERS.md @@ -0,0 +1,159 @@ +# Provider Registry + +This registry describes provider behavior that is wired into the current +CodeWhale codebase. It is intentionally conservative: shipped entries are +limited to provider IDs, config keys, auth paths, base URLs, model resolution, +and capability metadata that the code already knows about. + +DeepSeek remains the first-class default provider. NVIDIA NIM, OpenRouter, +Novita, Fireworks, generic OpenAI-compatible endpoints, self-hosted runtimes, +and Moonshot/Kimi are additive routes for running the same terminal harness +against other hosted or local model endpoints. Hugging Face Inference Providers +are a planned additive open-model routing layer; they are not a native provider +in this checkout yet. + +Sources to keep in sync: + +- `crates/config/src/lib.rs` - shared provider IDs, defaults, env precedence. +- `crates/tui/src/config.rs` - TUI provider IDs, provider capability metadata, + and provider-specific env handling. +- `crates/agent/src/lib.rs` - static `ModelRegistry` used by + `codewhale model list` and `codewhale model resolve`. +- `config.example.toml` and `docs/CONFIGURATION.md` - user-facing config + examples and environment variable reference. + +## Provider Selection + +The canonical provider IDs are: + +`deepseek`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, +`novita`, `fireworks`, `moonshot`, `sglang`, `vllm`, and `ollama`. + +Use any of these surfaces to select a provider: + +- CLI: `codewhale --provider ` +- TUI: `/provider ` or the provider picker +- Env: `CODEWHALE_PROVIDER=`; `DEEPSEEK_PROVIDER=` is the legacy alias +- Config: `provider = ""` + +`deepseek-cn`, `deepseek_china`, `deepseekcn`, and `deepseek-china` are accepted +as legacy aliases for `deepseek`. They do not select a different official host; +DeepSeek uses the same official API host worldwide. + +Fresh shared config writes to `~/.codewhale/config.toml`. Existing +`~/.deepseek/config.toml` files are still read for compatibility. + +## Auth And Env Rules + +For hosted providers, `codewhale auth set --provider ` saves an API key for +that provider. API-key environment variables are fallback inputs after saved +config and keyring credentials; an explicit process-level `--api-key` still +wins for that launch. + +For base URL and model selection, prefer: + +- `CODEWHALE_BASE_URL` / `CODEWHALE_MODEL` for the active provider. +- Provider-specific base URL/model env vars when listed below. +- `DEEPSEEK_BASE_URL`, `DEEPSEEK_MODEL`, and `DEEPSEEK_DEFAULT_TEXT_MODEL` as + legacy aliases. + +Non-local `http://` base URLs are rejected unless +`DEEPSEEK_ALLOW_INSECURE_HTTP=1` is set. Loopback HTTP URLs are allowed for +self-hosted runtimes. + +## Shipped Providers + +| Provider ID | TOML table | Auth env | Base URL env and default | Default or static models | Notes | +| --- | --- | --- | --- | --- | --- | +| `deepseek` | `[providers.deepseek]` | `DEEPSEEK_API_KEY` | `CODEWHALE_BASE_URL` / `DEEPSEEK_BASE_URL`; default `https://api.deepseek.com/beta` | `deepseek-v4-pro`, `deepseek-v4-flash`; compatibility aliases `deepseek-chat`, `deepseek-reasoner` | First-class default. Beta URL enables strict tool mode, chat prefix completion, and FIM completion. Set `https://api.deepseek.com` or `/v1` explicitly to opt out of beta-only features. | +| `nvidia-nim` | `[providers.nvidia_nim]` | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY`, fallback `DEEPSEEK_API_KEY` | `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, `NVIDIA_BASE_URL`; default `https://integrate.api.nvidia.com/v1` | `deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash` | Hosted DeepSeek V4 through NVIDIA NIM. `NVIDIA_NIM_MODEL` is accepted by the TUI config path. | +| `openai` | `[providers.openai]` | `OPENAI_API_KEY` | `OPENAI_BASE_URL`; default `https://api.openai.com/v1` | Registry entries: `deepseek-v4-pro`, `deepseek-v4-flash`; default config model `deepseek-v4-pro` | Generic OpenAI-compatible route for gateways and custom endpoints. Use this for explicit third-party OpenAI-compatible routes instead of inventing a new provider ID. `OPENAI_MODEL` is accepted. | +| `atlascloud` | `[providers.atlascloud]` | `ATLASCLOUD_API_KEY` | `ATLASCLOUD_BASE_URL`; default `https://api.atlascloud.ai/v1` | Default config model `deepseek-ai/deepseek-v4-flash` | OpenAI-compatible hosted route. `ATLASCLOUD_MODEL` is accepted by the TUI config path. The static `ModelRegistry` does not currently list AtlasCloud rows. | +| `wanjie-ark` | `[providers.wanjie_ark]` | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` | `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, `WANJIE_MAAS_BASE_URL`; default `https://maas-openapi.wanjiedata.com/api/v1` | `deepseek-reasoner` | OpenAI-compatible hosted route. `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, and `WANJIE_MAAS_MODEL` are accepted. | +| `openrouter` | `[providers.openrouter]` | `OPENROUTER_API_KEY` | `OPENROUTER_BASE_URL`; default `https://openrouter.ai/api/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | Additive open-model routing layer. It does not replace DeepSeek; it lets users route supported model IDs through OpenRouter when they choose it. | +| `novita` | `[providers.novita]` | `NOVITA_API_KEY` | `NOVITA_BASE_URL`; default `https://api.novita.ai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | OpenAI-compatible hosted route for DeepSeek model IDs. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. | +| `fireworks` | `[providers.fireworks]` | `FIREWORKS_API_KEY` | `FIREWORKS_BASE_URL`; default `https://api.fireworks.ai/inference/v1` | `accounts/fireworks/models/deepseek-v4-pro` | OpenAI-compatible hosted route. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. | +| `moonshot` | `[providers.moonshot]` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` | `MOONSHOT_BASE_URL`, `KIMI_BASE_URL`; default `https://api.moonshot.ai/v1` | `kimi-k2.6`; Kimi Code path uses `kimi-for-coding` at `https://api.kimi.com/coding/v1` | Moonshot/Kimi route. `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, and `KIMI_MODEL` are accepted. `[providers.moonshot] auth_mode = "kimi_oauth"` reads Kimi CLI OAuth credentials when present. | +| `sglang` | `[providers.sglang]` | Optional `SGLANG_API_KEY` | `SGLANG_BASE_URL`; default `http://localhost:30000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted OpenAI-compatible route. Localhost deployments commonly omit auth. `SGLANG_MODEL` is accepted. | +| `vllm` | `[providers.vllm]` | Optional `VLLM_API_KEY` | `VLLM_BASE_URL`; default `http://localhost:8000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted vLLM OpenAI-compatible route. Localhost deployments commonly omit auth. `VLLM_MODEL` is accepted. | +| `ollama` | `[providers.ollama]` | Optional `OLLAMA_API_KEY` | `OLLAMA_BASE_URL`; default `http://localhost:11434/v1` | `deepseek-coder:1.3b`; provider-hinted custom tags pass through | Self-hosted Ollama OpenAI-compatible route. Localhost deployments commonly omit auth. `OLLAMA_MODEL` is accepted. | + +## Static Model Registry + +`codewhale model list` and `codewhale model resolve` use the static registry in +`crates/agent/src/lib.rs`. This is not the same as live `/models` discovery. +Use `/models` or `codewhale models` to fetch model IDs from the active API +endpoint when the endpoint supports model listing. + +| Provider | Static registry entries | Tool calls | Registry reasoning flag | +| --- | --- | --- | --- | +| `deepseek` | `deepseek-v4-pro`, `deepseek-v4-flash` | yes | yes | +| `nvidia-nim` | `deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash` | yes | yes | +| `openai` | `deepseek-v4-pro`, `deepseek-v4-flash` | yes | yes | +| `wanjie-ark` | `deepseek-reasoner` | yes | yes | +| `openrouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | yes | yes | +| `novita` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | yes | yes | +| `fireworks` | `accounts/fireworks/models/deepseek-v4-pro` | yes | yes | +| `moonshot` | `kimi-k2.6` | yes | yes | +| `sglang` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes | +| `vllm` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes | +| `ollama` | `deepseek-coder:1.3b`; custom tags pass through when provider hint is `ollama` | yes | no | + +The registry currently has no AtlasCloud entry even though AtlasCloud is a +supported provider in config and TUI selection. AtlasCloud should use the +configured model or live model listing. + +## Capability Metadata + +`codewhale-tui doctor --json` exposes the `capability` object. It is static +metadata, not a live API probe. Current fields are: + +`resolved_provider`, `resolved_model`, `context_window`, `max_output`, +`thinking_supported`, `cache_telemetry_supported`, and `request_payload_mode`. + +All shipped providers use the Chat Completions request payload mode today. + +| Provider/model class | Context window | Max output metadata | Thinking support | Cache telemetry | FIM endpoint | +| --- | --- | --- | --- | --- | --- | +| DeepSeek V4 (`deepseek-v4-pro`, `deepseek-v4-flash`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only | +| DeepSeek compatibility aliases (`deepseek-chat`, `deepseek-reasoner`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only | +| NVIDIA NIM V4 registry models | 1,000,000 | 384,000 | yes | yes | not documented in code | +| OpenRouter, Novita, Fireworks, SGLang, and vLLM V4 model IDs | 1,000,000 | 384,000 | yes | no | not documented in code | +| Wanjie Ark `reasoner` / `r1` model IDs | 128,000 | 4,096 | yes | no | not documented in code | +| Generic `openai`, AtlasCloud, and Moonshot/Kimi | 128,000 | 4,096 | no in doctor capability metadata | no | not documented in code | +| Ollama | 8,192 | 4,096 | no | no | not documented in code | +| Other recognized DeepSeek model IDs | 128,000 unless the model name carries an explicit `Nk` hint | 4,096 | no unless V4/reasoner logic matches | DeepSeek/NIM only | DeepSeek beta only | + +Tool-call support is tracked separately by the static `ModelRegistry` and by +the endpoint's ability to accept OpenAI-compatible `tools` payloads. A custom +OpenAI-compatible or local endpoint can still reject tool calls even if +CodeWhale can send the schema. + +DeepSeek compatibility aliases `deepseek-chat` and `deepseek-reasoner` map to +`deepseek-v4-flash` capability metadata and are scheduled to retire on +2026-07-24 at 2026-07-24T15:59:00Z. + +## Planned, Not Shipped Yet + +These items belong to the v0.8.47 provider-abstraction milestone or related +provider docs work, but they are not native shipped behavior in this checkout: + +- A unified `Provider` trait in `codewhale-agent` that owns env precedence, + secret resolution, base URL normalization, auth-header construction, and + provider metadata. Those responsibilities are still split across + `crates/config`, `crates/secrets`, and `crates/tui/src/client.rs`. +- A native Hugging Face provider such as `[providers.huggingface]`. +- Native Hugging Face auth envs such as `HF_TOKEN` or `HUGGINGFACE_API_KEY`. +- A default Hugging Face router base URL such as + `https://router.huggingface.co/v1`. +- Hugging Face model passport metadata in the picker, including license, base + model, context length, chat template, tool-call support, reasoning support, + and gated/private status. +- A generated drift-check script that fails when this file diverges from the + provider registry. Until that exists, update this file with a source read of + the files listed at the top. + +Until native Hugging Face support lands, users can only reach an explicitly +configured Hugging Face-compatible OpenAI route through the generic `openai` +provider. That is an explicit user-selected route, not built-in Hub discovery +or a replacement for DeepSeek. From 8d48b19b5d8e41b651656d7f91ab6a5405467381 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 16:37:33 -0500 Subject: [PATCH 099/283] test: add regression coverage for edit_file fuzz omission (#2138) - Test that edit_file accepts calls with fuzz omitted, fuzz=false, and fuzz=true - Verify fuzz is excluded from schema required fields but present as optional boolean - Add agent-mode catalog test confirming edit_file is loaded and fuzz-less calls execute - Update existing required-fields assertions to check for exactly path/search/replace --- crates/tui/src/core/engine/tests.rs | 64 +++++++++++++++++++++++++++++ crates/tui/src/tools/file.rs | 43 ++++++++++++++++++- 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 422fdc11..f138726e 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -472,6 +472,70 @@ fn model_tool_catalog_applies_native_and_mcp_deferral() { assert_eq!(defer_loading("mcp_server_write"), Some(true)); } +#[test] +fn agent_catalog_keeps_edit_file_loaded_when_fuzz_is_omitted() { + let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); + let registry = engine + .build_turn_tool_registry_builder( + AppMode::Agent, + engine.config.todos.clone(), + engine.config.plan_state.clone(), + ) + .build(engine.build_tool_context(AppMode::Agent, false)); + let always_load = HashSet::new(); + let catalog = build_model_tool_catalog( + registry.to_api_tools_with_cache(true), + vec![], + AppMode::Agent, + &always_load, + ); + let edit = catalog + .iter() + .find(|tool| tool.name == "edit_file") + .expect("edit_file registered"); + + assert_eq!(edit.defer_loading, Some(false)); + let required = edit.input_schema["required"] + .as_array() + .expect("edit_file schema should include required fields"); + assert!(required.iter().any(|field| field.as_str() == Some("path"))); + assert!( + required + .iter() + .any(|field| field.as_str() == Some("search")) + ); + assert!( + required + .iter() + .any(|field| field.as_str() == Some("replace")) + ); + assert!(!required.iter().any(|field| field.as_str() == Some("fuzz"))); + assert_eq!( + edit.input_schema["properties"]["fuzz"]["type"].as_str(), + Some("boolean") + ); + + let active_at_batch_start = initial_active_tools(&catalog); + assert!(active_at_batch_start.contains("edit_file")); + let mut hydrated_this_batch = HashSet::new(); + assert!( + maybe_hydrate_requested_deferred_tool( + "edit_file", + &json!({ + "path": "src/foo.rs", + "search": "before", + "replace": "after" + }), + &catalog, + &active_at_batch_start, + &mut hydrated_this_batch, + ) + .is_none(), + "loaded edit_file calls without fuzz should execute instead of hydrating the schema" + ); + assert!(hydrated_this_batch.is_empty()); +} + #[test] fn tools_always_load_overrides_default_native_deferral() { let always_load = HashSet::from(["git_show".to_string()]); diff --git a/crates/tui/src/tools/file.rs b/crates/tui/src/tools/file.rs index 97ebadd8..068c2030 100644 --- a/crates/tui/src/tools/file.rs +++ b/crates/tui/src/tools/file.rs @@ -1475,6 +1475,41 @@ mod tests { assert_eq!(edited, "hi world hi"); } + #[tokio::test] + async fn test_edit_file_accepts_omitted_and_explicit_fuzz() { + let tmp = tempdir().expect("tempdir"); + let ctx = ToolContext::new(tmp.path().to_path_buf()); + let tool = EditFileTool; + + for (file_name, fuzz) in [ + ("fuzz_omitted.txt", None), + ("fuzz_false.txt", Some(false)), + ("fuzz_true.txt", Some(true)), + ] { + let test_file = tmp.path().join(file_name); + fs::write(&test_file, "hello world").expect("write"); + + let mut input = serde_json::Map::from_iter([ + ("path".to_string(), json!(file_name)), + ("search".to_string(), json!("hello")), + ("replace".to_string(), json!("hi")), + ]); + if let Some(fuzz) = fuzz { + input.insert("fuzz".to_string(), json!(fuzz)); + } + + let result = tool + .execute(Value::Object(input), &ctx) + .await + .expect("execute"); + + assert!(result.success, "{file_name}: {}", result.content); + assert!(result.content.contains("Replaced 1 occurrence")); + let edited = fs::read_to_string(&test_file).expect("read"); + assert_eq!(edited, "hi world"); + } + } + #[tokio::test] async fn test_edit_file_single_match_has_no_multi_match_warning() { let tmp = tempdir().expect("tempdir"); @@ -1827,7 +1862,13 @@ mod tests { .get("required") .and_then(|value| value.as_array()) .expect("edit schema should include required array"); - assert_eq!(required.len(), 3); + let required_fields: Vec<_> = required.iter().filter_map(|value| value.as_str()).collect(); + assert_eq!(required_fields, vec!["path", "search", "replace"]); + assert!(!required_fields.contains(&"fuzz")); + assert_eq!( + edit_schema["properties"]["fuzz"]["type"].as_str(), + Some("boolean") + ); let search_desc = edit_schema["properties"]["search"]["description"] .as_str() .expect("search description"); From 91ab9089ab52f53b80b1aa58708239ffc2b1f34c Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 16:37:53 -0500 Subject: [PATCH 100/283] fix: add DEEPSEEK_ALLOW_INSECURE_HTTP env guard for LAN vLLM (#1656) - Add env guard with process-global mutex for ALLOW_INSECURE_HTTP tests - Document LAN vLLM HTTP example in README - Add config test for reported LAN HTTP endpoint + model resolution - Guard validate_base_url_security tests against env leak --- README.md | 2 ++ crates/tui/src/client.rs | 40 ++++++++++++++++++++++++++++++++++++++++ crates/tui/src/config.rs | 29 +++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+) diff --git a/README.md b/README.md index 334ae74f..65094773 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,8 @@ SGLANG_BASE_URL="http://localhost:30000/v1" codewhale --provider sglang --model # Self-hosted vLLM VLLM_BASE_URL="http://localhost:8000/v1" codewhale --provider vllm --model deepseek-v4-flash +# Trusted LAN vLLM over HTTP +DEEPSEEK_ALLOW_INSECURE_HTTP=1 VLLM_BASE_URL="http://192.168.0.110:8000/v1" codewhale --provider vllm --model deepseek-v4-flash # Self-hosted Ollama ollama pull codewhale-coder:1.3b diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 025a8344..b89825ac 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -2862,6 +2862,10 @@ mod tests { #[test] fn base_url_security_rejects_insecure_non_local_http() { + let _lock = ALLOW_INSECURE_HTTP_ENV_LOCK.lock().unwrap(); + let _guard = AllowInsecureHttpEnvGuard::capture(); + unsafe { std::env::remove_var(ALLOW_INSECURE_HTTP_ENV) }; + let err = validate_base_url_security("http://api.deepseek.com") .expect_err("non-local insecure HTTP should be rejected"); assert!(err.to_string().contains("Refusing insecure base URL")); @@ -2869,10 +2873,46 @@ mod tests { #[test] fn base_url_security_allows_localhost_http() { + let _lock = ALLOW_INSECURE_HTTP_ENV_LOCK.lock().unwrap(); + let _guard = AllowInsecureHttpEnvGuard::capture(); + unsafe { std::env::remove_var(ALLOW_INSECURE_HTTP_ENV) }; + assert!(validate_base_url_security("http://localhost:8080").is_ok()); assert!(validate_base_url_security("http://127.0.0.1:8080").is_ok()); } + #[test] + fn base_url_security_allows_non_local_http_with_explicit_opt_in() { + let _lock = ALLOW_INSECURE_HTTP_ENV_LOCK.lock().unwrap(); + let _guard = AllowInsecureHttpEnvGuard::capture(); + unsafe { std::env::set_var(ALLOW_INSECURE_HTTP_ENV, "1") }; + + assert!(validate_base_url_security("http://192.168.0.110:8000/v1").is_ok()); + } + + /// Serialize tests that mutate `DEEPSEEK_ALLOW_INSECURE_HTTP`; env vars are + /// process-global and would otherwise leak across security checks. + static ALLOW_INSECURE_HTTP_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + struct AllowInsecureHttpEnvGuard { + prior: Option, + } + impl AllowInsecureHttpEnvGuard { + fn capture() -> Self { + Self { + prior: std::env::var_os(ALLOW_INSECURE_HTTP_ENV), + } + } + } + impl Drop for AllowInsecureHttpEnvGuard { + fn drop(&mut self) { + match &self.prior { + Some(v) => unsafe { std::env::set_var(ALLOW_INSECURE_HTTP_ENV, v) }, + None => unsafe { std::env::remove_var(ALLOW_INSECURE_HTTP_ENV) }, + } + } + } + #[test] fn connection_health_degrades_and_recovers() { let now = Instant::now(); diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 63fd1e80..46fb0023 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -6445,6 +6445,35 @@ model = "qwen2.5-coder:7b" Ok(()) } + #[test] + fn vllm_env_resolves_reported_lan_http_endpoint_and_model() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-vllm-lan-http-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "vllm"); + env::set_var("VLLM_BASE_URL", "http://192.168.0.110:8000/v1"); + env::set_var("DEEPSEEK_MODEL", "deepseek-v4-flash"); + } + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Vllm); + assert_eq!(config.deepseek_base_url(), "http://192.168.0.110:8000/v1"); + assert_eq!(config.default_model(), "deepseek-v4-flash"); + Ok(()) + } + #[test] fn ollama_env_overrides_base_url_and_model() -> Result<()> { let _lock = lock_test_env(); From 99941d9d01cca9e9854bc72fa8b1876ff1268d4b Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 16:37:53 -0500 Subject: [PATCH 101/283] fix: include tool catalog in cache-inspect prefix hashing (#1818) - Track tool catalog as a static layer in prompt inspection - Include tools in cache-warmup request with tool_choice=none - Ensure tool schema changes are visible to base-static-prefix diagnostics - Factor test_tool helper for cache-inspect test coverage --- crates/tui/src/client.rs | 64 ++++++++++++++++++++++++++- crates/tui/src/client/chat.rs | 82 ++++++++++++++++++++++++----------- 2 files changed, 119 insertions(+), 27 deletions(-) diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 025a8344..80a6a009 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -1125,6 +1125,23 @@ mod tests { }; use serde_json::json; + fn test_tool(name: &str) -> Tool { + Tool { + tool_type: None, + name: name.to_string(), + description: format!("{name} test tool"), + input_schema: json!({ + "type": "object", + "properties": {}, + }), + allowed_callers: None, + defer_loading: Some(false), + input_examples: None, + strict: Some(true), + cache_control: None, + } + } + #[test] fn tool_name_roundtrip_dot() { let original = "multi_tool_use.parallel"; @@ -1810,6 +1827,49 @@ mod tests { )); } + #[test] + fn prompt_inspect_tracks_tool_catalog_in_static_prefix_hash() { + let request = MessageRequest { + model: "deepseek-v4-pro".to_string(), + messages: vec![Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "Current task".to_string(), + cache_control: None, + }], + }], + max_tokens: 1024, + system: Some(SystemPrompt::Text("Base policy".to_string())), + tools: Some(vec![test_tool("read_file")]), + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: Some("max".to_string()), + stream: None, + temperature: None, + top_p: None, + }; + + let first = inspect_prompt_for_request(&request); + let mut changed_tools = request.clone(); + changed_tools.tools = Some(vec![test_tool("read_file"), test_tool("grep_files")]); + let second = inspect_prompt_for_request(&changed_tools); + + assert!( + first.layers.iter().any(|layer| { + layer.name == "Tool catalog" && layer.stability.label() == "static" + }) + ); + assert_ne!( + first.base_static_prefix_hash, second.base_static_prefix_hash, + "tool schema changes must be visible to cache-inspect base prefix diagnostics" + ); + assert_ne!( + first.full_request_prefix_hash, second.full_request_prefix_hash, + "tool schema changes must be visible to full reusable-prefix diagnostics" + ); + } + #[test] fn cache_warmup_request_reuses_stable_prefix_and_fixed_user_tail() { let request = MessageRequest { @@ -1835,7 +1895,7 @@ mod tests { "Base policy\n\n\nStable project rules\n\n\n## Previous Session Relay\n\nDynamic relay" .to_string(), )), - tools: None, + tools: Some(vec![test_tool("read_file")]), tool_choice: None, metadata: None, thinking: None, @@ -1850,6 +1910,8 @@ mod tests { assert_eq!(warmup.max_tokens, 8); assert_eq!(warmup.temperature, Some(0.0)); assert_eq!(warmup.reasoning_effort.as_deref(), Some("max")); + assert_eq!(warmup.tools.as_ref().map(Vec::len), Some(1)); + assert_eq!(warmup.tool_choice, Some(json!("none"))); assert_eq!(warmup.messages.len(), 2); assert_eq!(warmup.messages[0].role, "assistant"); assert_eq!(warmup.messages[1].role, "user"); diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index dd6c37a4..656330fc 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -438,6 +438,7 @@ pub(crate) fn build_cache_warmup_request(request: &MessageRequest) -> MessageReq struct PromptBuilder<'a> { system: Option<&'a SystemPrompt>, messages: &'a [Message], + tools: Option<&'a [Tool]>, model: &'a str, reasoning_effort: Option<&'a str>, } @@ -447,6 +448,7 @@ impl<'a> PromptBuilder<'a> { Self { system: request.system.as_ref(), messages: &request.messages, + tools: request.tools.as_deref(), model: &request.model, reasoning_effort: request.reasoning_effort.as_deref(), } @@ -485,12 +487,17 @@ impl<'a> PromptBuilder<'a> { should_replay_reasoning_content(self.model, self.reasoning_effort), true, ); - inspect_wire_messages(&messages) + inspect_wire_request(self.tools, &messages) } fn build_cache_warmup_request(self) -> MessageRequest { let system = stable_system_prompt(self.system); let mut messages = stable_history_messages(self.messages); + let tools = self + .tools + .filter(|tools| !tools.is_empty()) + .map(<[Tool]>::to_vec); + let tool_choice = tools.as_ref().map(|_| json!("none")); messages.push(Message { role: "user".to_string(), content: vec![ContentBlock::Text { @@ -504,8 +511,8 @@ impl<'a> PromptBuilder<'a> { messages, max_tokens: 8, system, - tools: None, - tool_choice: None, + tools, + tool_choice, metadata: None, thinking: None, reasoning_effort: self.reasoning_effort.map(str::to_string), @@ -581,20 +588,19 @@ impl PromptLayerStability { } } -fn inspect_wire_messages(messages: &[Value]) -> PromptInspection { +fn inspect_wire_request(tools: Option<&[Tool]>, messages: &[Value]) -> PromptInspection { let mut layers = Vec::new(); let mut base_static_prefix_parts = Vec::new(); let mut full_request_prefix_parts = Vec::new(); + let mut start_index = 0; - for (index, message) in messages.iter().enumerate() { + if let Some(message) = messages.first() { let role = message .get("role") .and_then(Value::as_str) .unwrap_or("unknown"); let content = message_content_for_inspect(message); - let is_last = index + 1 == messages.len(); - - if index == 0 && role == "system" { + if role == "system" { for (name, stability, body) in split_system_layers(&content) { if stability == PromptLayerStability::Static { base_static_prefix_parts.push(body.to_string()); @@ -604,27 +610,46 @@ fn inspect_wire_messages(messages: &[Value]) -> PromptInspection { } layers.push(prompt_layer(name, stability, body)); } - } else { - let stability = if (is_last && role == "user") || role == "tool" { - PromptLayerStability::Dynamic - } else { - PromptLayerStability::History - }; - let name = if is_last && role == "user" { - "User task".to_string() - } else { - format!("Message #{index} {role}") - }; - if stability != PromptLayerStability::Dynamic { - full_request_prefix_parts.push(content.clone()); - } - let mut layer = prompt_layer(name, stability, &content); - layer.tool_result = tool_result_inspection_for_message(message); - layer.turn_meta = turn_meta_inspection_for_message(message); - layers.push(layer); + start_index = 1; } } + if let Some(tool_catalog) = tool_catalog_for_inspect(tools) { + base_static_prefix_parts.push(tool_catalog.clone()); + full_request_prefix_parts.push(tool_catalog.clone()); + layers.push(prompt_layer( + "Tool catalog".to_string(), + PromptLayerStability::Static, + &tool_catalog, + )); + } + + for (index, message) in messages.iter().enumerate().skip(start_index) { + let role = message + .get("role") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let content = message_content_for_inspect(message); + let is_last = index + 1 == messages.len(); + let stability = if (is_last && role == "user") || role == "tool" { + PromptLayerStability::Dynamic + } else { + PromptLayerStability::History + }; + let name = if is_last && role == "user" { + "User task".to_string() + } else { + format!("Message #{index} {role}") + }; + if stability != PromptLayerStability::Dynamic { + full_request_prefix_parts.push(content.clone()); + } + let mut layer = prompt_layer(name, stability, &content); + layer.tool_result = tool_result_inspection_for_message(message); + layer.turn_meta = turn_meta_inspection_for_message(message); + layers.push(layer); + } + let base_static_prefix = base_static_prefix_parts.join("\n"); let full_request_prefix = full_request_prefix_parts.join("\n"); @@ -635,6 +660,11 @@ fn inspect_wire_messages(messages: &[Value]) -> PromptInspection { } } +fn tool_catalog_for_inspect(tools: Option<&[Tool]>) -> Option { + let tools = tools.filter(|tools| !tools.is_empty())?; + serde_json::to_string(&tools.iter().map(tool_to_chat).collect::>()).ok() +} + fn message_content_for_inspect(message: &Value) -> String { let mut parts = Vec::new(); if let Some(content) = message.get("content").and_then(Value::as_str) From aeaf91d589bc954b8059da68af542f7598597f9c Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 16:37:53 -0500 Subject: [PATCH 102/283] feat(web_search): switch default backend from Bing to DuckDuckGo (#2132) - Make DuckDuckGo the default search provider with Bing fallback - Update tool description, config docs, TOOL_SURFACE, doctor output - Update all search default tests and references - Bing remains selectable via [search] provider = "bing" --- crates/tui/src/config.rs | 16 ++++++++-------- crates/tui/src/core/engine.rs | 2 +- crates/tui/src/main.rs | 9 +++++---- crates/tui/src/tools/spec.rs | 2 +- crates/tui/src/tools/web_search.rs | 2 +- docs/CONFIGURATION.md | 10 ++++++---- docs/TOOL_SURFACE.md | 2 +- 7 files changed, 23 insertions(+), 20 deletions(-) diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 63fd1e80..684e888a 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -648,9 +648,9 @@ impl SnapshotsConfig { #[serde(rename_all = "snake_case")] pub enum SearchProvider { /// Bing HTML scraping. No API key needed. - #[default] Bing, /// DuckDuckGo HTML scraping with Bing fallback. No API key needed. + #[default] #[serde(alias = "duckduckgo")] DuckDuckGo, /// Tavily AI Search API (). Requires api_key. @@ -714,7 +714,7 @@ pub struct SearchProviderResolution { /// Web search provider configuration (`[search]` table in config.toml). #[derive(Debug, Clone, Deserialize, Default)] pub struct SearchConfig { - /// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha` | `metaso`. Default: `bing`. + /// Search provider: `bing` | `duckduckgo` | `tavily` | `bocha` | `metaso`. Default: `duckduckgo`. #[serde(default)] pub provider: Option, /// API key for Tavily, Bocha, or Metaso. Not required for Bing or DuckDuckGo. @@ -1105,9 +1105,9 @@ pub struct Config { #[serde(default)] pub snapshots: Option, - /// Web search provider configuration. When absent, defaults to Bing. - /// Set `provider` to `duckduckgo`, `tavily`, or `bocha` to use those - /// services instead; Tavily and Bocha also require an `api_key`. + /// Web search provider configuration. When absent, defaults to DuckDuckGo. + /// Set `provider` to `bing`, `tavily`, or `bocha` to use those services + /// instead; Tavily and Bocha also require an `api_key`. #[serde(default)] pub search: Option, @@ -4208,8 +4208,8 @@ mod tests { } #[test] - fn search_provider_defaults_to_bing() { - assert_eq!(SearchProvider::default(), SearchProvider::Bing); + fn search_provider_defaults_to_duckduckgo() { + assert_eq!(SearchProvider::default(), SearchProvider::DuckDuckGo); } #[test] @@ -4254,7 +4254,7 @@ mod tests { let resolution = Config::default().search_provider_resolution(); unsafe { EnvGuard::restore_var("DEEPSEEK_SEARCH_PROVIDER", prev) }; - assert_eq!(resolution.provider, SearchProvider::Bing); + assert_eq!(resolution.provider, SearchProvider::DuckDuckGo); assert_eq!(resolution.source, SearchProviderSource::Default); } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index e81debe9..516a1f38 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -162,7 +162,7 @@ pub struct EngineConfig { pub strict_tool_mode: bool, /// Workshop / large-tool-output routing (#548). `None` disables routing. pub workshop: Option, - /// Which search backend `web_search` should use. Default: Bing. + /// Which search backend `web_search` should use. Default: DuckDuckGo. pub search_provider: crate::config::SearchProvider, /// API key for Tavily, Bocha, or Metaso. `None` for Bing or DuckDuckGo. /// Metaso also falls back to `METASO_API_KEY` env var, then a built-in key. diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 473484dc..352e6ea2 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -3163,11 +3163,11 @@ fn doctor_search_provider_line(config: &Config) -> String { let switch_hint = if matches!( (search_provider.provider, search_provider.source), ( - crate::config::SearchProvider::Bing, + crate::config::SearchProvider::DuckDuckGo, crate::config::SearchProviderSource::Default ) ) { - "; set [search] provider = \"duckduckgo\" | \"tavily\" | \"bocha\" to switch" + "; set [search] provider = \"bing\" | \"tavily\" | \"bocha\" to switch" } else { "" }; @@ -5701,7 +5701,7 @@ mod doctor_endpoint_tests { } #[test] - fn doctor_search_provider_line_includes_default_source_and_switch_hint() { + fn doctor_search_provider_line_includes_duckduckgo_default_source_and_switch_hint() { let _guard = crate::test_support::lock_test_env(); let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER"); unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") }; @@ -5712,9 +5712,10 @@ mod doctor_endpoint_tests { Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) }, None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") }, } - assert!(line.contains("search_provider: bing")); + assert!(line.contains("search_provider: duckduckgo")); assert!(line.contains("source: default")); assert!(line.contains("[search] provider")); + assert!(line.contains("provider = \"bing\"")); } #[test] diff --git a/crates/tui/src/tools/spec.rs b/crates/tui/src/tools/spec.rs index f13c6516..3e16a81d 100644 --- a/crates/tui/src/tools/spec.rs +++ b/crates/tui/src/tools/spec.rs @@ -162,7 +162,7 @@ pub struct ToolContext { /// routing (e.g. in sub-agents and test contexts to avoid recursion). pub large_output_router: Option, - /// Which search backend `web_search` should use. Default: Bing. Set via + /// Which search backend `web_search` should use. Default: DuckDuckGo. Set via /// `[search] provider` in config.toml. pub search_provider: crate::config::SearchProvider, /// API key for Tavily, Bocha, or Metaso. `None` for Bing or DuckDuckGo. diff --git a/crates/tui/src/tools/web_search.rs b/crates/tui/src/tools/web_search.rs index cb64ce98..16c7b632 100644 --- a/crates/tui/src/tools/web_search.rs +++ b/crates/tui/src/tools/web_search.rs @@ -129,7 +129,7 @@ impl ToolSpec for WebSearchTool { } fn description(&self) -> &'static str { - "Search the web and return ranked results with URLs and snippets. Default backend is Bing; set `[search] provider = \"duckduckgo\" | \"tavily\" | \"bocha\"` in config.toml to switch backends. Use this instead of scraping search engines with `curl` in `exec_shell`. For a known canonical URL, prefer `fetch_url` directly." + "Search the web and return ranked results with URLs and snippets. Default backend is DuckDuckGo with Bing fallback; set `[search] provider = \"bing\" | \"tavily\" | \"bocha\"` in config.toml to switch backends. Use this instead of scraping search engines with `curl` in `exec_shell`. For a known canonical URL, prefer `fetch_url` directly." } fn input_schema(&self) -> Value { diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index b92cd8ff..a40b41f5 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -654,14 +654,16 @@ Use `codewhale-tui features list` to inspect known flags and their effective sta ## Web Search Provider -`web_search` uses Bing by default and does not require an API key. DuckDuckGo -remains selectable for users who explicitly want it, and Tavily or Bocha can be -selected when an API-backed provider is preferred. **Metaso** ([metaso.cn](https://metaso.cn)) +`web_search` uses DuckDuckGo by default and does not require an API key. The +DuckDuckGo path keeps a Bing fallback when DDG returns a bot challenge or no +parseable results. Bing remains selectable for users who explicitly want it, +and Tavily or Bocha can be selected when an API-backed provider is preferred. +**Metaso** ([metaso.cn](https://metaso.cn)) 100 searches/day free quota — set `METASO_API_KEY` or `[search] api_key` for a higher quota. ```toml [search] -provider = "bing" # bing | duckduckgo | tavily | bocha | metaso +provider = "duckduckgo" # duckduckgo | bing | tavily | bocha | metaso # api_key = "YOUR_KEY" # required for tavily and bocha; optional for metaso (100 searches/day free quota) ``` diff --git a/docs/TOOL_SURFACE.md b/docs/TOOL_SURFACE.md index 1038b93e..aa31fb4e 100644 --- a/docs/TOOL_SURFACE.md +++ b/docs/TOOL_SURFACE.md @@ -35,7 +35,7 @@ chosen over the available shell equivalent. Companion to `crates/tui/src/prompts |---|---| | `grep_files` | Regex search file contents within the workspace; structured matches + context lines. Pure-Rust (`regex` crate), no `rg`/`grep` shell-out. | | `file_search` | Fuzzy-match filenames (not contents). Use when you know roughly the name. | -| `web_search` | Bing by default; DuckDuckGo, Tavily, and Bocha are selectable in config. Ranked snippets + `ref_id` for citation. | +| `web_search` | DuckDuckGo by default with Bing fallback; Bing, Tavily, and Bocha are selectable in config. Ranked snippets + `ref_id` for citation. | | `fetch_url` | Direct HTTP GET on a known URL. Faster than `web_search` when the link is already known. HTML stripped to text by default. | ### Shell From 6fce7dca384f3ff96f2dea4dbfca68bad8f63e91 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 16:38:26 -0500 Subject: [PATCH 103/283] fix(lang): keep hidden reasoning_content in English regardless of locale (#1842/#1843) - Add show_thinking flag to PromptSessionContext - When show_thinking=false, emit hidden-thinking English instruction - Omit locale-reinforcement bookends when user can't see thinking blocks - Keep final-visible-reply language rule unchanged - Add test for hidden-thinking language directive --- crates/tui/src/core/engine.rs | 11 +++ crates/tui/src/core/engine/tests.rs | 22 ++++++ crates/tui/src/core/ops.rs | 1 + crates/tui/src/main.rs | 11 +-- crates/tui/src/prompts.rs | 104 +++++++++++++++++++++++++++- crates/tui/src/runtime_threads.rs | 14 ++-- crates/tui/src/tui/ui.rs | 3 + crates/tui/src/tui/ui/tests.rs | 11 ++- 8 files changed, 164 insertions(+), 13 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index e81debe9..184327a4 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -99,6 +99,9 @@ pub struct EngineConfig { /// When true, the model is instructed to respond in the current locale /// and a post-hoc translation layer replaces remaining English output. pub translation_enabled: bool, + /// Whether user-visible transcript rendering shows thinking blocks. + /// Prompt assembly uses this to avoid localizing hidden reasoning. + pub show_thinking: bool, /// Maximum number of assistant steps before stopping. pub max_steps: u32, /// Maximum number of concurrently active subagents. @@ -194,6 +197,7 @@ impl Default for EngineConfig { instructions: Vec::new(), project_context_pack_enabled: true, translation_enabled: false, + show_thinking: true, max_steps: 100, max_subagents: DEFAULT_MAX_SUBAGENTS, features: Features::with_defaults(), @@ -455,6 +459,7 @@ impl Engine { locale_tag: &config.locale_tag, translation_enabled: config.translation_enabled, model_id: &config.model, + show_thinking: config.show_thinking, }, session.approval_mode, ); @@ -610,6 +615,7 @@ impl Engine { auto_approve, approval_mode, translation_enabled, + show_thinking, } => { self.handle_send_message( content, @@ -624,6 +630,7 @@ impl Engine { auto_approve, approval_mode, translation_enabled, + show_thinking, ) .await; } @@ -830,6 +837,7 @@ impl Engine { self.session.auto_approve, self.session.approval_mode, self.config.translation_enabled, + self.config.show_thinking, ) .await; } @@ -918,6 +926,7 @@ impl Engine { auto_approve: bool, approval_mode: crate::tui::approval::ApprovalMode, translation_enabled: bool, + show_thinking: bool, ) { // Reset cancel token for fresh turn (in case previous was cancelled) self.reset_cancel_token(); @@ -1011,6 +1020,7 @@ impl Engine { self.session.trust_mode = trust_mode; self.config.trust_mode = trust_mode; self.config.translation_enabled = translation_enabled; + self.config.show_thinking = show_thinking; self.session.auto_approve = auto_approve; self.session.approval_mode = if auto_approve { crate::tui::approval::ApprovalMode::Auto @@ -1852,6 +1862,7 @@ impl Engine { locale_tag: &self.config.locale_tag, translation_enabled: self.config.translation_enabled, model_id: &self.config.model, + show_thinking: self.config.show_thinking, }, self.session.approval_mode, ); diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 422fdc11..db843260 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -1509,6 +1509,28 @@ fn refresh_system_prompt_is_noop_when_unchanged() { assert_eq!(engine.session.system_prompt, first_prompt); } +#[test] +fn engine_prompt_respects_hidden_thinking_config() { + let tmp = tempdir().expect("tempdir"); + let config = EngineConfig { + workspace: tmp.path().to_path_buf(), + locale_tag: "zh-Hans".to_string(), + show_thinking: false, + ..Default::default() + }; + let (engine, _handle) = Engine::new(config, &Config::default()); + let prompt = match engine.session.system_prompt.as_ref() { + Some(SystemPrompt::Text(text)) => text, + Some(SystemPrompt::Blocks(_)) => panic!("expected text system prompt"), + None => panic!("expected system prompt"), + }; + + assert!(prompt.contains("## Hidden Thinking Language")); + assert!(prompt.contains("reasoning_content")); + assert!(prompt.contains("English")); + assert!(!prompt.contains("## 语言再次提醒")); +} + fn sync_runtime_system_prompt_override(engine: &mut Engine, system_prompt: SystemPrompt) { engine.session.compaction_summary_prompt = extract_compaction_summary_prompt(Some(system_prompt.clone())); diff --git a/crates/tui/src/core/ops.rs b/crates/tui/src/core/ops.rs index a77a2625..b4096706 100644 --- a/crates/tui/src/core/ops.rs +++ b/crates/tui/src/core/ops.rs @@ -31,6 +31,7 @@ pub enum Op { auto_approve: bool, approval_mode: ApprovalMode, translation_enabled: bool, + show_thinking: bool, }, /// Cancel the current request diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 473484dc..17bafe9d 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -5160,6 +5160,7 @@ async fn run_exec_agent( .lsp .clone() .map(crate::config::LspConfigToml::into_runtime); + let settings = crate::settings::Settings::load().unwrap_or_default(); let engine_config = EngineConfig { model: effective_model.clone(), @@ -5172,6 +5173,7 @@ async fn run_exec_agent( instructions: config.instructions_paths(), project_context_pack_enabled: config.project_context_pack_enabled(), translation_enabled: false, + show_thinking: settings.show_thinking, max_steps: 100, max_subagents, features: config.features(), @@ -5197,11 +5199,9 @@ async fn run_exec_agent( vision_config: config.vision_model_config(), strict_tool_mode: config.strict_tool_mode.unwrap_or(false), goal_objective: None, - locale_tag: crate::localization::resolve_locale( - &crate::settings::Settings::load().unwrap_or_default().locale, - ) - .tag() - .to_string(), + locale_tag: crate::localization::resolve_locale(&settings.locale) + .tag() + .to_string(), workshop: config.workshop.clone(), search_provider: config.search_provider(), search_api_key: config.search.as_ref().and_then(|s| s.api_key.clone()), @@ -5260,6 +5260,7 @@ async fn run_exec_agent( trust_mode, auto_approve, translation_enabled: false, + show_thinking: settings.show_thinking, approval_mode: if auto_approve { crate::tui::approval::ApprovalMode::Auto } else { diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index aa69f4f7..5584df68 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -34,6 +34,10 @@ pub struct PromptSessionContext<'a> { /// preserving backward compatibility with existing call sites /// that predate dynamic model injection. pub model_id: &'a str, + /// Whether the user-visible transcript renders thinking blocks. + /// When false, the prompt should not spend localization pressure on + /// `reasoning_content` the user will never see. + pub show_thinking: bool, } impl Default for PromptSessionContext<'_> { @@ -45,6 +49,7 @@ impl Default for PromptSessionContext<'_> { locale_tag: "en", translation_enabled: false, model_id: "codewhale", + show_thinking: true, } } } @@ -104,6 +109,25 @@ fn translation_target_language_for_tag(locale_tag: &str) -> &'static str { } } +fn hidden_thinking_language_instruction(locale_tag: &str) -> String { + let fallback_language = translation_target_language_for_tag(locale_tag); + format!( + "\ +## Hidden Thinking Language\n\ +\n\ +The user has disabled thinking display (`show_thinking = false`). If you emit \ +`reasoning_content`, keep that hidden internal thinking in English regardless \ +of the latest user-message language or `## Environment.lang`; the user will \ +not see it, so localizing hidden thinking only adds language switching.\n\ +\n\ +The final reply is still user-visible. Follow the normal `## Language` rule \ +for the final reply: mirror the latest user message, and use \ +{fallback_language} only when the user message is ambiguous. If the user \ +explicitly asks for a different thinking language, follow that explicit request \ +for the current turn." + ) +} + /// Render a `## Environment` block listing the resolved locale tag, /// runtime version, host platform, login shell, and current working directory. /// @@ -611,6 +635,7 @@ pub fn system_prompt_for_mode_with_context_and_skills( locale_tag: "en", translation_enabled: false, model_id: "codewhale", + show_thinking: true, }, ) } @@ -657,7 +682,11 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval( // in English even though `lang: zh-Hans` is set" failure mode that // PR #1398 partially addressed. English (and unknown) locales get // `None` and keep the previous behavior unchanged. - let preamble = locale_reinforcement_preamble(session_context.locale_tag); + let preamble = if session_context.show_thinking { + locale_reinforcement_preamble(session_context.locale_tag) + } else { + None + }; // 1–2. Mode prompt + project context. // `load_project_context_with_parents` auto-generates .codewhale/instructions.md @@ -806,8 +835,17 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval( // rule immediately before it generates `reasoning_content` for the // turn. English (and unknown) locales return `None` and the prompt // stays byte-identical to the pre-bookend behavior. - if let Some(closer) = locale_reinforcement_closer(session_context.locale_tag) { + if let Some(closer) = session_context + .show_thinking + .then(|| locale_reinforcement_closer(session_context.locale_tag)) + .flatten() + { full_prompt = format!("{full_prompt}\n\n{closer}"); + } else if !session_context.show_thinking { + full_prompt = format!( + "{full_prompt}\n\n{}", + hidden_thinking_language_instruction(session_context.locale_tag) + ); } SystemPrompt::Text(full_prompt) @@ -1087,6 +1125,7 @@ mod tests { locale_tag: "zh-Hans", translation_enabled: false, model_id: "codewhale", + show_thinking: true, }, ApprovalMode::Suggest, ) { @@ -1157,6 +1196,7 @@ mod tests { locale_tag: "zh-Hans", translation_enabled: false, model_id: "codewhale", + show_thinking: true, }, ApprovalMode::Suggest, ) { @@ -1184,6 +1224,58 @@ mod tests { ); } + #[test] + fn hidden_thinking_uses_english_reasoning_without_locale_bookends() { + let tmp = tempdir().expect("tempdir"); + let text = match system_prompt_for_mode_with_context_skills_session_and_approval( + AppMode::Agent, + tmp.path(), + None, + None, + None, + PromptSessionContext { + user_memory_block: None, + goal_objective: None, + project_context_pack_enabled: false, + locale_tag: "zh-Hans", + translation_enabled: false, + model_id: "codewhale", + show_thinking: false, + }, + ApprovalMode::Suggest, + ) { + SystemPrompt::Text(text) => text, + SystemPrompt::Blocks(_) => panic!("expected text system prompt"), + }; + + assert!( + text.contains("## Hidden Thinking Language"), + "hidden thinking prompt must include the request-side language override" + ); + assert!( + text.contains("reasoning_content") && text.contains("English"), + "hidden thinking override must steer reasoning_content to English" + ); + assert!( + text.contains("final reply") && text.contains("Simplified Chinese"), + "hidden thinking override must preserve the visible reply language" + ); + assert!( + !text.contains("## 语言要求") && !text.contains("## 语言再次提醒"), + "hidden thinking prompt must not also ask for localized reasoning" + ); + + let hidden_pos = text + .find("## Hidden Thinking Language") + .expect("hidden thinking block present"); + let hidden_header_end = hidden_pos + "## Hidden Thinking Language".len(); + let after_hidden_body = &text[hidden_header_end..]; + assert!( + !after_hidden_body.contains("\n## "), + "hidden thinking override must be the final top-level block; got: {after_hidden_body:?}", + ); + } + #[test] fn system_prompt_skips_locale_preamble_for_english() { // English locale → no preamble injected. Asserts the @@ -1202,6 +1294,7 @@ mod tests { locale_tag: "en", translation_enabled: false, model_id: "codewhale", + show_thinking: true, }, ApprovalMode::Suggest, ) { @@ -1295,6 +1388,7 @@ mod tests { locale_tag: "ja", translation_enabled: false, model_id: "codewhale", + show_thinking: true, }, ) { SystemPrompt::Text(text) => text, @@ -1331,6 +1425,7 @@ mod tests { locale_tag: "en", translation_enabled: false, model_id: "codewhale", + show_thinking: true, }, ) { SystemPrompt::Text(text) => text, @@ -1359,6 +1454,7 @@ mod tests { locale_tag: "en", translation_enabled: false, model_id: "codewhale", + show_thinking: true, }, ) { SystemPrompt::Text(text) => text, @@ -1416,6 +1512,7 @@ mod tests { locale_tag: "en", translation_enabled: false, model_id: "codewhale", + show_thinking: true, }, ) { SystemPrompt::Text(text) => text, @@ -1444,6 +1541,7 @@ mod tests { locale_tag: "en", translation_enabled: false, model_id: "codewhale", + show_thinking: true, }, ) { SystemPrompt::Text(text) => text, @@ -1639,6 +1737,7 @@ mod tests { locale_tag: "en", translation_enabled: false, model_id: "codewhale", + show_thinking: true, }, ) { SystemPrompt::Text(text) => text, @@ -1673,6 +1772,7 @@ mod tests { locale_tag: "en", translation_enabled: false, model_id: "codewhale", + show_thinking: true, }, ) { SystemPrompt::Text(text) => text, diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 9cd65087..78580eaf 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1611,6 +1611,9 @@ impl RuntimeThreadManager { let allow_shell = req.allow_shell.unwrap_or(thread.allow_shell); let trust_mode = req.trust_mode.unwrap_or(thread.trust_mode); let auto_approve = req.auto_approve.unwrap_or(thread.auto_approve); + let show_thinking = crate::settings::Settings::load() + .unwrap_or_default() + .show_thinking; engine .send(Op::SendMessage { @@ -1625,6 +1628,7 @@ impl RuntimeThreadManager { trust_mode, auto_approve, translation_enabled: false, + show_thinking, approval_mode: if auto_approve { crate::tui::approval::ApprovalMode::Auto } else { @@ -1931,6 +1935,7 @@ impl RuntimeThreadManager { .lsp .clone() .map(crate::config::LspConfigToml::into_runtime); + let settings = crate::settings::Settings::load().unwrap_or_default(); let engine_cfg = EngineConfig { model: thread.model.clone(), workspace: thread.workspace.clone(), @@ -1942,6 +1947,7 @@ impl RuntimeThreadManager { instructions: self.config.instructions_paths(), project_context_pack_enabled: self.config.project_context_pack_enabled(), translation_enabled: false, + show_thinking: settings.show_thinking, max_steps: 100, max_subagents: self.config.max_subagents().clamp(1, MAX_SUBAGENTS), features: self.config.features(), @@ -1982,11 +1988,9 @@ impl RuntimeThreadManager { vision_config: self.config.vision_model_config(), strict_tool_mode: self.config.strict_tool_mode.unwrap_or(false), goal_objective: None, - locale_tag: crate::localization::resolve_locale( - &crate::settings::Settings::load().unwrap_or_default().locale, - ) - .tag() - .to_string(), + locale_tag: crate::localization::resolve_locale(&settings.locale) + .tag() + .to_string(), workshop: self.config.workshop.clone(), search_provider: self.config.search_provider(), search_api_key: self.config.search.as_ref().and_then(|s| s.api_key.clone()), diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 2ba52ef9..db3d6c8f 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -684,6 +684,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { instructions: config.instructions_paths(), project_context_pack_enabled: config.project_context_pack_enabled(), translation_enabled: app.translation_enabled, + show_thinking: app.show_thinking, // Effectively unlimited. V4 has a 1M context window and the user // wants the model running until it's actually done. The previous cap // of 100 hit the ceiling on long multi-step plans (wide refactors, @@ -4147,6 +4148,7 @@ async fn dispatch_user_message( locale_tag: app.ui_locale.tag(), translation_enabled: app.translation_enabled, model_id: &app.model, + show_thinking: app.show_thinking, }, ), ); @@ -4243,6 +4245,7 @@ async fn dispatch_user_message( auto_approve: app.mode == AppMode::Yolo, approval_mode: app.approval_mode, translation_enabled: app.translation_enabled, + show_thinking: app.show_thinking, }) .await { diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index c73a6a6e..61a734b1 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -3140,6 +3140,7 @@ async fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() { #[tokio::test] async fn dispatch_user_message_records_prompt_for_cancel_restore() { let mut app = create_test_app(); + app.show_thinking = false; let config = Config::default(); let mut engine = crate::core::engine::mock_engine_handle(); let queued = crate::tui::app::QueuedMessage::new("fix this typo\nthen retry".to_string(), None); @@ -3153,8 +3154,16 @@ async fn dispatch_user_message_records_prompt_for_cancel_restore() { Some("fix this typo\nthen retry") ); match engine.rx_op.recv().await.expect("send message op") { - crate::core::ops::Op::SendMessage { content, .. } => { + crate::core::ops::Op::SendMessage { + content, + show_thinking, + .. + } => { assert_eq!(content, "fix this typo\nthen retry"); + assert!( + !show_thinking, + "dispatch must carry the user's hidden-thinking setting into the engine" + ); } other => panic!("expected SendMessage, got {other:?}"), } From cf8880e4647870cf64764b70b9a92f9bc437acc1 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 16:38:26 -0500 Subject: [PATCH 104/283] fix(session): compact large tool outputs to artifact receipts on persist (#2021) - Replace giant tool-result strings in session JSON with TOOL_OUTPUT_RECEIPT markers - Reference artifact records by id for retrieval after session load - Compact on save, checkpoint, load, and load_by_id paths - Wire tool_output_receipts module into session_manager - Add tests for compaction-on-save and legacy-load-then-compact paths --- crates/tui/src/commands/session.rs | 7 +- crates/tui/src/commands/status.rs | 44 +++ crates/tui/src/main.rs | 1 + crates/tui/src/session_manager.rs | 139 ++++++- crates/tui/src/tool_output_receipts.rs | 507 +++++++++++++++++++++++++ 5 files changed, 692 insertions(+), 6 deletions(-) create mode 100644 crates/tui/src/tool_output_receipts.rs diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index bc51683d..acfc0ccb 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -48,7 +48,9 @@ pub fn save(app: &mut App, path: Option<&str>) -> CommandResult { match std::fs::create_dir_all(&sessions_dir) { Ok(()) => { - let json = match serde_json::to_string_pretty(&session) { + let mut persisted = session.clone(); + crate::session_manager::compact_session_tool_outputs(&mut persisted); + let json = match serde_json::to_string_pretty(&persisted) { Ok(j) => j, Err(e) => return CommandResult::error(format!("Failed to serialize session: {e}")), }; @@ -152,12 +154,13 @@ pub fn load(app: &mut App, path: Option<&str>) -> CommandResult { } }; - let session: crate::session_manager::SavedSession = match serde_json::from_str(&content) { + let mut session: crate::session_manager::SavedSession = match serde_json::from_str(&content) { Ok(s) => s, Err(e) => { return CommandResult::error(format!("Failed to parse session file: {e}")); } }; + crate::session_manager::compact_session_tool_outputs(&mut session); app.api_messages.clone_from(&session.messages); app.clear_history(); diff --git a/crates/tui/src/commands/status.rs b/crates/tui/src/commands/status.rs index 2370a06d..fb1a7e6d 100644 --- a/crates/tui/src/commands/status.rs +++ b/crates/tui/src/commands/status.rs @@ -103,6 +103,13 @@ fn format_status(app: &App) -> String { app.api_messages.len() ), ); + let tool_output_status = + crate::tool_output_receipts::tool_output_status(&app.api_messages, &app.session_artifacts); + push_row( + &mut out, + "Tool outputs:", + &crate::tool_output_receipts::format_tool_output_status(&tool_output_status), + ); push_row( &mut out, "Rate limits:", @@ -257,11 +264,48 @@ mod tests { assert!(msg.contains("Session:")); assert!(msg.contains("session-123")); assert!(msg.contains("Context window:")); + assert!(msg.contains("Tool outputs:")); assert!(msg.contains("Cache hit/miss:")); assert!(msg.contains("70 hit / 30 miss")); assert!(msg.contains("Use /statusline to configure footer items.")); } + #[test] + fn status_report_surfaces_large_tool_output_pressure() { + let tmpdir = TempDir::new().expect("temp dir"); + let mut app = create_test_app(tmpdir.path().to_path_buf()); + let raw = "RAW_STATUS_PRESSURE\n".repeat(2_000); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "call-big".to_string(), + content: raw, + is_error: None, + content_blocks: None, + }], + }); + app.session_artifacts + .push(crate::artifacts::ArtifactRecord { + id: "art_call-big".to_string(), + kind: crate::artifacts::ArtifactKind::ToolOutput, + session_id: "session-123".to_string(), + tool_call_id: "call-big".to_string(), + tool_name: "exec_shell".to_string(), + created_at: chrono::Utc::now(), + byte_size: 24_000, + preview: "large output".to_string(), + storage_path: PathBuf::from("artifacts/art_call-big.txt"), + }); + + let result = status(&mut app); + let msg = result.message.expect("status message"); + + assert!(msg.contains("Tool outputs:")); + assert!(msg.contains("raw over cap")); + assert!(msg.contains("context pressure")); + assert!(msg.contains("artifact")); + } + #[test] fn project_docs_reports_missing_docs() { let tmpdir = TempDir::new().expect("temp dir"); diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 473484dc..f49369a9 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -70,6 +70,7 @@ mod task_manager; #[cfg(test)] mod test_support; mod theme_qa_audit; +mod tool_output_receipts; mod tools; mod tui; mod utils; diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index 93fcb56c..cf13a388 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -261,7 +261,10 @@ impl SessionManager { pub fn save_session(&self, session: &SavedSession) -> std::io::Result { let path = self.validated_session_path(&session.metadata.id)?; - let content = serde_json::to_string_pretty(session) + let mut persisted = session.clone(); + compact_session_tool_outputs(&mut persisted); + + let content = serde_json::to_string_pretty(&persisted) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; // Atomic write via write_atomic (NamedTempFile + fsync + persist) @@ -278,7 +281,9 @@ impl SessionManager { let checkpoints = self.sessions_dir.join("checkpoints"); fs::create_dir_all(&checkpoints)?; let path = checkpoints.join("latest.json"); - let content = serde_json::to_string_pretty(session) + let mut persisted = session.clone(); + compact_session_tool_outputs(&mut persisted); + let content = serde_json::to_string_pretty(&persisted) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; write_atomic(&path, content.as_bytes())?; Ok(path) @@ -291,7 +296,7 @@ impl SessionManager { return Ok(None); } let content = fs::read_to_string(&path)?; - let session: SavedSession = serde_json::from_str(&content) + let mut session: SavedSession = serde_json::from_str(&content) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; if session.schema_version > CURRENT_SESSION_SCHEMA_VERSION { return Err(std::io::Error::new( @@ -302,6 +307,7 @@ impl SessionManager { ), )); } + compact_session_tool_outputs(&mut session); Ok(Some(session)) } @@ -372,7 +378,7 @@ impl SessionManager { let path = self.validated_session_path(id)?; let content = fs::read_to_string(&path)?; - let session: SavedSession = serde_json::from_str(&content) + let mut session: SavedSession = serde_json::from_str(&content) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; if session.schema_version > CURRENT_SESSION_SCHEMA_VERSION { return Err(std::io::Error::new( @@ -384,6 +390,7 @@ impl SessionManager { )); } + compact_session_tool_outputs(&mut session); Ok(session) } @@ -760,6 +767,17 @@ pub fn update_session( session } +pub(crate) fn compact_session_tool_outputs( + session: &mut SavedSession, +) -> crate::tool_output_receipts::ToolOutputReceiptStats { + let (messages, stats) = crate::tool_output_receipts::compact_messages_for_persistence( + &session.messages, + &session.artifacts, + ); + session.messages = messages; + stats +} + /// Cap messages to [`MAX_PERSISTED_MESSAGES`], keeping the most recent. /// Returns the capped slice and an optional truncation note. fn cap_messages(messages: &[Message]) -> (Vec, Option) { @@ -1119,6 +1137,119 @@ mod tests { assert_eq!(loaded.messages.len(), 2); } + #[test] + fn save_session_compacts_large_tool_outputs_to_artifact_receipts() { + let tmp = tempdir().expect("tempdir"); + let manager = SessionManager::new(tmp.path().join("sessions")).expect("new"); + let raw = "RAW_SESSION_SENTINEL\n".repeat(2_000); + let messages = vec![ + Message { + role: "assistant".to_string(), + content: vec![ContentBlock::ToolUse { + id: "call-big".to_string(), + name: "exec_shell".to_string(), + input: serde_json::json!({"command": "cargo test -p codewhale-tui"}), + caller: None, + }], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "call-big".to_string(), + content: raw.clone(), + is_error: None, + content_blocks: None, + }], + }, + ]; + let mut session = create_saved_session(&messages, "test-model", tmp.path(), 100, None); + session.artifacts.push(crate::artifacts::ArtifactRecord { + id: "art_call-big".to_string(), + kind: crate::artifacts::ArtifactKind::ToolOutput, + session_id: session.metadata.id.clone(), + tool_call_id: "call-big".to_string(), + tool_name: "exec_shell".to_string(), + created_at: Utc::now(), + byte_size: raw.len() as u64, + preview: "checking crate ... error[E0425]".to_string(), + storage_path: PathBuf::from("artifacts/art_call-big.txt"), + }); + + let path = manager.save_session(&session).expect("save"); + let persisted_json = fs::read_to_string(path).expect("read persisted session"); + assert!(!persisted_json.contains("RAW_SESSION_SENTINEL")); + + let loaded = manager.load_session(&session.metadata.id).expect("load"); + let ContentBlock::ToolResult { content, .. } = &loaded.messages[1].content[0] else { + panic!("expected loaded tool result"); + }; + assert!(!content.contains("RAW_SESSION_SENTINEL")); + assert!(content.contains("[TOOL_OUTPUT_RECEIPT]")); + assert!(content.contains("detail_handle: art_call-big")); + assert!(content.contains("retrieve: retrieve_tool_result ref=art_call-big")); + } + + #[test] + fn load_session_compacts_legacy_large_tool_outputs_before_resume() { + let tmp = tempdir().expect("tempdir"); + let manager = SessionManager::new(tmp.path().join("sessions")).expect("new"); + let raw = "RAW_LEGACY_RESUME_SENTINEL\n".repeat(2_000); + let messages = vec![ + Message { + role: "assistant".to_string(), + content: vec![ContentBlock::ToolUse { + id: "call-legacy".to_string(), + name: "exec_shell".to_string(), + input: serde_json::json!({"command": "cargo check"}), + caller: None, + }], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "call-legacy".to_string(), + content: raw.clone(), + is_error: None, + content_blocks: None, + }], + }, + ]; + let mut session = create_saved_session(&messages, "test-model", tmp.path(), 100, None); + session.artifacts.push(crate::artifacts::ArtifactRecord { + id: "art_call-legacy".to_string(), + kind: crate::artifacts::ArtifactKind::ToolOutput, + session_id: session.metadata.id.clone(), + tool_call_id: "call-legacy".to_string(), + tool_name: "exec_shell".to_string(), + created_at: Utc::now(), + byte_size: raw.len() as u64, + preview: "cargo check output".to_string(), + storage_path: PathBuf::from("artifacts/art_call-legacy.txt"), + }); + let path = manager + .validated_session_path(&session.metadata.id) + .expect("path"); + fs::write( + &path, + serde_json::to_string_pretty(&session).expect("serialize legacy session"), + ) + .expect("write legacy raw session"); + assert!( + fs::read_to_string(&path) + .expect("read legacy raw") + .contains("RAW_LEGACY_RESUME_SENTINEL") + ); + + let loaded = manager.load_session(&session.metadata.id).expect("load"); + let ContentBlock::ToolResult { content, .. } = &loaded.messages[1].content[0] else { + panic!("expected loaded tool result"); + }; + assert!(!content.contains("RAW_LEGACY_RESUME_SENTINEL")); + assert!(content.contains("[TOOL_OUTPUT_RECEIPT]")); + assert!(content.contains("detail_handle: art_call-legacy")); + assert!(content.contains("retrieve: retrieve_tool_result ref=art_call-legacy")); + } + #[test] fn test_list_sessions() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/tool_output_receipts.rs b/crates/tui/src/tool_output_receipts.rs new file mode 100644 index 00000000..715255a8 --- /dev/null +++ b/crates/tui/src/tool_output_receipts.rs @@ -0,0 +1,507 @@ +//! Compact receipts for oversized tool outputs in saved session history. + +use std::collections::HashMap; + +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::artifacts::{ArtifactKind, ArtifactRecord, format_artifact_relative_path}; +use crate::models::{ContentBlock, Message}; +use crate::tools::truncate; + +/// Match the provider-wire budget so persisted/resumed history does not keep a +/// larger raw body than the model would receive on a fresh request. +pub const RAW_TOOL_OUTPUT_RECEIPT_THRESHOLD_CHARS: usize = 12_000; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ToolOutputReceiptStats { + pub compacted_count: usize, + pub artifact_receipts: usize, + pub sha_receipts: usize, + pub unavailable_receipts: usize, + pub original_chars: usize, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ToolOutputStatus { + pub raw_large_count: usize, + pub raw_large_chars: usize, + pub receipt_count: usize, + pub artifact_count: usize, + pub artifact_bytes: u64, +} + +#[derive(Debug, Clone)] +struct ToolUseInfo { + name: String, + input: Value, +} + +#[derive(Debug, Clone)] +enum DetailHandle { + Artifact(ArtifactRecord), + Sha { sha: String, persisted: bool }, +} + +/// Return a copy of `messages` with oversized raw tool-result bodies replaced +/// by compact receipts. Full output is kept behind existing session artifacts +/// when available; otherwise a SHA-addressed spillover copy is written for +/// `retrieve_tool_result`. +pub fn compact_messages_for_persistence( + messages: &[Message], + artifacts: &[ArtifactRecord], +) -> (Vec, ToolOutputReceiptStats) { + let artifacts_by_call = artifacts_by_tool_call(artifacts); + let mut tool_uses: HashMap = HashMap::new(); + let mut stats = ToolOutputReceiptStats::default(); + let mut compacted = Vec::with_capacity(messages.len()); + + for message in messages { + let mut next = message.clone(); + for block in &mut next.content { + match block { + ContentBlock::ToolUse { + id, name, input, .. + } => { + tool_uses.insert( + id.clone(), + ToolUseInfo { + name: name.clone(), + input: input.clone(), + }, + ); + } + ContentBlock::ToolResult { + tool_use_id, + content, + is_error, + .. + } => { + let char_count = content.chars().count(); + if char_count <= RAW_TOOL_OUTPUT_RECEIPT_THRESHOLD_CHARS + || looks_like_receipt(content) + { + continue; + } + + let tool_info = tool_uses.get(tool_use_id); + let handle = artifacts_by_call + .get(tool_use_id.as_str()) + .cloned() + .map(|artifact| DetailHandle::Artifact((*artifact).clone())) + .unwrap_or_else(|| DetailHandle::Sha { + sha: sha256_hex(content.as_bytes()), + persisted: persist_sha_tool_result(content), + }); + let source = match &handle { + DetailHandle::Artifact(_) => ReceiptSource::Artifact, + DetailHandle::Sha { + persisted: true, .. + } => ReceiptSource::Sha, + DetailHandle::Sha { + persisted: false, .. + } => ReceiptSource::Unavailable, + }; + + *content = render_tool_output_receipt( + tool_use_id, + tool_info, + content, + *is_error, + &handle, + ); + stats.compacted_count += 1; + stats.original_chars = stats.original_chars.saturating_add(char_count); + match source { + ReceiptSource::Artifact => stats.artifact_receipts += 1, + ReceiptSource::Sha => stats.sha_receipts += 1, + ReceiptSource::Unavailable => stats.unavailable_receipts += 1, + } + } + _ => {} + } + } + compacted.push(next); + } + + (compacted, stats) +} + +pub fn tool_output_status(messages: &[Message], artifacts: &[ArtifactRecord]) -> ToolOutputStatus { + let mut status = ToolOutputStatus { + artifact_count: artifacts.len(), + artifact_bytes: artifacts + .iter() + .map(|artifact| artifact.byte_size) + .sum::(), + ..ToolOutputStatus::default() + }; + + for message in messages { + for block in &message.content { + if let ContentBlock::ToolResult { content, .. } = block { + if looks_like_receipt(content) { + status.receipt_count += 1; + } else { + let chars = content.chars().count(); + if chars > RAW_TOOL_OUTPUT_RECEIPT_THRESHOLD_CHARS { + status.raw_large_count += 1; + status.raw_large_chars = status.raw_large_chars.saturating_add(chars); + } + } + } + } + } + + status +} + +pub fn format_tool_output_status(status: &ToolOutputStatus) -> String { + let mut parts = Vec::new(); + if status.raw_large_count > 0 { + parts.push(format!( + "{} raw over cap (~{} chars) adding context pressure", + status.raw_large_count, + format_count(status.raw_large_chars) + )); + } + if status.receipt_count > 0 { + parts.push(format!("{} compact receipt(s)", status.receipt_count)); + } + if status.artifact_count > 0 { + parts.push(format!( + "{} artifact(s), {} stored", + status.artifact_count, + crate::artifacts::format_byte_size(status.artifact_bytes) + )); + } + if parts.is_empty() { + "no large outputs tracked".to_string() + } else { + parts.join("; ") + } +} + +fn artifacts_by_tool_call(artifacts: &[ArtifactRecord]) -> HashMap<&str, &ArtifactRecord> { + artifacts + .iter() + .filter(|artifact| artifact.kind == ArtifactKind::ToolOutput) + .map(|artifact| (artifact.tool_call_id.as_str(), artifact)) + .collect() +} + +#[derive(Debug, Clone, Copy)] +enum ReceiptSource { + Artifact, + Sha, + Unavailable, +} + +fn render_tool_output_receipt( + tool_call_id: &str, + tool_info: Option<&ToolUseInfo>, + original_content: &str, + is_error: Option, + handle: &DetailHandle, +) -> String { + let original_chars = original_content.chars().count(); + let original_bytes = original_content.len() as u64; + let tool_name = match handle { + DetailHandle::Artifact(record) if !record.tool_name.trim().is_empty() => { + record.tool_name.as_str() + } + _ => tool_info + .map(|info| info.name.as_str()) + .filter(|name| !name.trim().is_empty()) + .unwrap_or("unknown"), + }; + let command_or_query = tool_info + .map(|info| summarize_input(&info.input, 300)) + .unwrap_or_else(|| "unknown".to_string()); + let status = if is_error.unwrap_or(false) { + "error" + } else { + "success" + }; + let exit_status = infer_exit_status(original_content).unwrap_or_else(|| "unknown".to_string()); + let preview = preview_for_receipt(handle, original_content); + let (detail_handle, retrieve, storage) = match handle { + DetailHandle::Artifact(record) => ( + record.id.clone(), + format!("retrieve_tool_result ref={}", record.id), + format_artifact_relative_path(&record.storage_path), + ), + DetailHandle::Sha { sha, persisted } => { + let handle = format!("sha:{sha}"); + let storage = if *persisted { + "content-addressed spillover".to_string() + } else { + "unavailable; spillover write failed".to_string() + }; + ( + handle.clone(), + format!("retrieve_tool_result ref={handle}"), + storage, + ) + } + }; + + format!( + "[TOOL_OUTPUT_RECEIPT]\n\ + tool: {tool_name}\n\ + tool_call_id: {tool_call_id}\n\ + status: {status}\n\ + exit_status: {exit_status}\n\ + elapsed: unknown\n\ + output: {bytes} ({chars} chars, ~{tokens} tokens)\n\ + truncation: raw output omitted from saved/resumed context\n\ + detail_handle: {detail_handle}\n\ + retrieve: {retrieve}\n\ + storage: {storage}\n\ + command_or_query: {command_or_query}\n\ + preview: {preview}\n\ + [/TOOL_OUTPUT_RECEIPT]", + bytes = crate::artifacts::format_byte_size(original_bytes), + chars = format_count(original_chars), + tokens = format_count(approx_tokens(original_chars)), + ) +} + +fn persist_sha_tool_result(content: &str) -> bool { + let sha = sha256_hex(content.as_bytes()); + match truncate::write_sha_spillover(&sha, content) { + Ok(_) => true, + Err(err) => { + crate::logging::warn(format!( + "tool-output receipt SHA spillover write failed for sha={sha}: {err}" + )); + false + } + } +} + +fn preview_for_receipt(handle: &DetailHandle, original_content: &str) -> String { + let preview = match handle { + DetailHandle::Artifact(record) if !record.preview.trim().is_empty() => { + record.preview.as_str() + } + _ => original_content, + }; + summarize_text(preview, 240) +} + +fn looks_like_receipt(content: &str) -> bool { + let trimmed = content.trim_start(); + trimmed.starts_with("[TOOL_OUTPUT_RECEIPT]") + || trimmed.starts_with("[artifact:") + || trimmed.starts_with("[TOOL_RESULT_TRUNCATED]") + || trimmed.starts_with(" Option { + if let Ok(value) = serde_json::from_str::(content) { + for key in ["exit_code", "exit_status", "status", "code"] { + if let Some(value) = value.get(key) { + return Some(summarize_input(value, 120)); + } + } + } + + for line in content.lines().take(40) { + let trimmed = line.trim(); + for prefix in ["Exit code:", "exit code:", "Exit status:", "exit status:"] { + if let Some(value) = trimmed.strip_prefix(prefix) { + return Some(summarize_text(value.trim(), 120)); + } + } + } + None +} + +fn summarize_input(value: &Value, max_chars: usize) -> String { + let raw = value + .as_str() + .map(str::to_string) + .unwrap_or_else(|| value.to_string()); + summarize_text(&raw, max_chars) +} + +fn summarize_text(text: &str, max_chars: usize) -> String { + let escaped = text.replace('\n', "\\n"); + let mut summary: String = escaped.chars().take(max_chars).collect(); + if escaped.chars().count() > max_chars { + summary.push_str("..."); + } + summary +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} + +fn approx_tokens(chars: usize) -> usize { + chars.div_ceil(4) +} + +fn format_count(value: usize) -> String { + value.to_string() +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use chrono::Utc; + use serde_json::json; + use tempfile::tempdir; + + use super::*; + + fn tool_use_message(id: &str, name: &str, input: Value) -> Message { + Message { + role: "assistant".to_string(), + content: vec![ContentBlock::ToolUse { + id: id.to_string(), + name: name.to_string(), + input, + caller: None, + }], + } + } + + fn tool_result_message(id: &str, content: &str) -> Message { + Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: id.to_string(), + content: content.to_string(), + is_error: None, + content_blocks: None, + }], + } + } + + fn artifact_record(tool_call_id: &str, raw: &str) -> ArtifactRecord { + ArtifactRecord { + id: crate::artifacts::artifact_id_for_tool_call(tool_call_id), + kind: ArtifactKind::ToolOutput, + session_id: "session-123".to_string(), + tool_call_id: tool_call_id.to_string(), + tool_name: "exec_shell".to_string(), + created_at: Utc::now(), + byte_size: raw.len() as u64, + preview: "checking crate ... error[E0425]".to_string(), + storage_path: PathBuf::from("artifacts").join("art_call-big.txt"), + } + } + + #[test] + fn compacts_large_tool_result_to_artifact_receipt() { + let raw = "RAW_SENTINEL\n".repeat(2_000); + let messages = vec![ + tool_use_message( + "call-big", + "exec_shell", + json!({"command": "cargo test -p codewhale-tui"}), + ), + tool_result_message("call-big", &raw), + ]; + let artifacts = vec![artifact_record("call-big", &raw)]; + + let (compacted, stats) = compact_messages_for_persistence(&messages, &artifacts); + let ContentBlock::ToolResult { content, .. } = &compacted[1].content[0] else { + panic!("expected tool result"); + }; + + assert_eq!(stats.compacted_count, 1); + assert_eq!(stats.artifact_receipts, 1); + assert!(!content.contains("RAW_SENTINEL")); + assert!(content.contains("[TOOL_OUTPUT_RECEIPT]")); + assert!(content.contains("tool: exec_shell")); + assert!(content.contains("detail_handle: art_call-big")); + assert!(content.contains("retrieve: retrieve_tool_result ref=art_call-big")); + assert!( + content.contains("command_or_query: {\"command\":\"cargo test -p codewhale-tui\"}") + ); + } + + #[test] + fn compacts_large_tool_result_to_sha_receipt_when_no_artifact_exists() { + let _guard = crate::tools::truncate::TEST_SPILLOVER_GUARD + .lock() + .unwrap_or_else(|err| err.into_inner()); + let tmp = tempdir().expect("tempdir"); + let prior = crate::tools::truncate::set_test_spillover_root(Some( + tmp.path().join(".deepseek").join("tool_outputs"), + )); + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + crate::tools::truncate::set_test_spillover_root(self.0.take()); + } + } + let _restore = Restore(prior); + + let raw = format!("{}\n{}", "H".repeat(320), "NO_ARTIFACT_RAW\n".repeat(2_000)); + let sha = sha256_hex(raw.as_bytes()); + let messages = vec![ + tool_use_message("call-big", "grep_files", json!({"pattern": "TODO"})), + tool_result_message("call-big", &raw), + ]; + + let (compacted, stats) = compact_messages_for_persistence(&messages, &[]); + let ContentBlock::ToolResult { content, .. } = &compacted[1].content[0] else { + panic!("expected tool result"); + }; + + assert_eq!(stats.compacted_count, 1); + assert_eq!(stats.sha_receipts, 1); + assert!(!content.contains("NO_ARTIFACT_RAW")); + assert!(content.contains(&format!("detail_handle: sha:{sha}"))); + assert!(content.contains(&format!("retrieve: retrieve_tool_result ref=sha:{sha}"))); + let path = crate::tools::truncate::sha_spillover_path(&sha).expect("sha path"); + assert_eq!(std::fs::read_to_string(path).expect("read sha"), raw); + } + + #[test] + fn small_tool_results_remain_inline() { + let messages = vec![ + tool_use_message("call-small", "exec_shell", json!({"command": "pwd"})), + tool_result_message("call-small", "ok"), + ]; + + let (compacted, stats) = compact_messages_for_persistence(&messages, &[]); + let ContentBlock::ToolResult { content, .. } = &compacted[1].content[0] else { + panic!("expected tool result"); + }; + + assert_eq!(content, "ok"); + assert_eq!(stats.compacted_count, 0); + } + + #[test] + fn status_reports_raw_large_receipts_and_artifacts() { + let raw = "RAW_STATUS\n".repeat(2_000); + let receipt = "[TOOL_OUTPUT_RECEIPT]\ndetail_handle: art_call-big"; + let messages = vec![ + tool_result_message("call-raw", &raw), + tool_result_message("call-receipt", receipt), + ]; + let artifacts = vec![ArtifactRecord { + storage_path: Path::new("artifacts/art_call-big.txt").to_path_buf(), + ..artifact_record("call-big", &raw) + }]; + + let status = tool_output_status(&messages, &artifacts); + assert_eq!(status.raw_large_count, 1); + assert_eq!(status.receipt_count, 1); + assert_eq!(status.artifact_count, 1); + + let rendered = format_tool_output_status(&status); + assert!(rendered.contains("raw over cap")); + assert!(rendered.contains("compact receipt")); + assert!(rendered.contains("artifact")); + } +} From 5e9accd14952045c0057fc9c5623cd035af1adc5 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 16:38:26 -0500 Subject: [PATCH 105/283] feat(diag): add redacted synthetic session failure classifier (#2022) - Add FailureCategory enum for command-exit, network, sandbox, timeout, etc. - Module is deliberately pure: no file reads, caller-provided records only - Added as dead_code module, wired for future diagnostics integration --- crates/tui/src/main.rs | 2 + crates/tui/src/session_failure_classifier.rs | 513 +++++++++++++++++++ 2 files changed, 515 insertions(+) create mode 100644 crates/tui/src/session_failure_classifier.rs diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 473484dc..94baec02 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -61,6 +61,8 @@ mod runtime_threads; mod sandbox; mod schema_migration; mod seam_manager; +#[allow(dead_code)] +mod session_failure_classifier; mod session_manager; mod settings; mod skill_state; diff --git a/crates/tui/src/session_failure_classifier.rs b/crates/tui/src/session_failure_classifier.rs new file mode 100644 index 00000000..a88b87c9 --- /dev/null +++ b/crates/tui/src/session_failure_classifier.rs @@ -0,0 +1,513 @@ +//! Redacted session/tool failure classification. +//! +//! This module is deliberately pure: callers provide already-parsed, +//! caller-constructed records and receive aggregate counts plus redacted +//! source handles. It does not read session files or copy raw tool output. + +use std::collections::BTreeMap; + +use serde::Serialize; + +/// Environment/tool failure shapes that should be separated from model-quality +/// failures during triage. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FailureCategory { + CommandExit, + Network, + SandboxApproval, + MissingDependencyPath, + Timeout, + UnclosedTurn, + Unknown, +} + +impl FailureCategory { + #[must_use] + pub fn is_environment_suspect(self) -> bool { + !matches!(self, Self::Unknown) + } +} + +/// One caller-supplied synthetic session record. +#[derive(Debug, Clone)] +pub struct SessionFailureRecord<'a> { + /// Untrusted source locator. The classifier hashes it before output. + pub source_hint: &'a str, + /// Optional timestamp to preserve enough local evidence metadata for + /// maintainers who have access to the private source. + pub timestamp: Option<&'a str>, + pub event: SessionFailureEvent<'a>, +} + +/// Synthetic event shape used by the classifier. +#[derive(Debug, Clone)] +pub enum SessionFailureEvent<'a> { + TurnStarted { turn_id: &'a str }, + TurnCompleted { turn_id: &'a str }, + Tool(ToolFailureRecord<'a>), +} + +/// Caller-supplied tool record. Text fields are classification inputs only and +/// are never copied into [`FailureEvidence`]. +#[derive(Debug, Clone, Default)] +pub struct ToolFailureRecord<'a> { + pub tool_name: &'a str, + pub success: Option, + pub exit_code: Option, + pub timed_out: bool, + pub sandbox_denied: bool, + pub approval_denied: bool, + pub diagnostic: Option<&'a str>, + pub output_excerpt: Option<&'a str>, +} + +/// Redacted per-failure locator emitted by default. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct FailureEvidence { + pub category: FailureCategory, + pub source_handle: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_handle: Option, +} + +/// Aggregate classifier output safe for status, handoff, or bug-report +/// preflight surfaces. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct FailureSummary { + pub counts: BTreeMap, + pub evidence: Vec, +} + +impl FailureSummary { + #[must_use] + pub fn count_for(&self, category: FailureCategory) -> usize { + self.counts.get(&category).copied().unwrap_or(0) + } + + #[must_use] + pub fn environment_suspect_count(&self) -> usize { + self.evidence + .iter() + .filter(|item| item.category.is_environment_suspect()) + .count() + } + + fn push(&mut self, evidence: FailureEvidence) { + *self.counts.entry(evidence.category).or_insert(0) += 1; + self.evidence.push(evidence); + } +} + +#[derive(Debug, Clone)] +struct OpenTurn { + source_handle: String, + timestamp: Option, + turn_handle: String, +} + +/// Classify a caller-supplied slice of synthetic records. +#[must_use] +pub fn summarize_records(records: &[SessionFailureRecord<'_>]) -> FailureSummary { + let mut summary = FailureSummary::default(); + let mut open_turns: BTreeMap = BTreeMap::new(); + + for record in records { + let source_handle = redacted_handle("src", record.source_hint); + let timestamp = record.timestamp.map(ToOwned::to_owned); + + match &record.event { + SessionFailureEvent::TurnStarted { turn_id } => { + open_turns.insert( + (*turn_id).to_owned(), + OpenTurn { + source_handle, + timestamp, + turn_handle: redacted_handle("turn", turn_id), + }, + ); + } + SessionFailureEvent::TurnCompleted { turn_id } => { + open_turns.remove(*turn_id); + } + SessionFailureEvent::Tool(tool) => { + if let Some(category) = classify_tool_record(tool) { + summary.push(FailureEvidence { + category, + source_handle, + timestamp, + tool_name: Some(sanitize_tool_name(tool.tool_name)), + exit_code: tool.exit_code.filter(|code| *code != 0), + turn_handle: None, + }); + } + } + } + } + + for turn in open_turns.into_values() { + summary.push(FailureEvidence { + category: FailureCategory::UnclosedTurn, + source_handle: turn.source_handle, + timestamp: turn.timestamp, + tool_name: None, + exit_code: None, + turn_handle: Some(turn.turn_handle), + }); + } + + summary +} + +/// Classify one tool record. Returns `None` for successful/no-signal records. +#[must_use] +pub fn classify_tool_record(record: &ToolFailureRecord<'_>) -> Option { + let failed = record.success == Some(false) + || record.exit_code.is_some_and(|code| code != 0) + || record.timed_out + || record.sandbox_denied + || record.approval_denied + || record.diagnostic.is_some() + || record.output_excerpt.is_some(); + + if !failed { + return None; + } + + if record.timed_out || record.matches_text(timeout_signal) { + return Some(FailureCategory::Timeout); + } + if record.sandbox_denied + || record.approval_denied + || record.matches_text(sandbox_or_approval_signal) + { + return Some(FailureCategory::SandboxApproval); + } + if record.matches_text(network_signal) { + return Some(FailureCategory::Network); + } + if record.matches_text(missing_dependency_or_path_signal) { + return Some(FailureCategory::MissingDependencyPath); + } + if record.exit_code.is_some_and(|code| code != 0) { + return Some(FailureCategory::CommandExit); + } + + Some(FailureCategory::Unknown) +} + +impl ToolFailureRecord<'_> { + fn matches_text(&self, predicate: fn(&str) -> bool) -> bool { + self.diagnostic.is_some_and(predicate) || self.output_excerpt.is_some_and(predicate) + } +} + +fn timeout_signal(text: &str) -> bool { + let lower = text.to_ascii_lowercase(); + lower.contains("timed out") + || lower.contains("timeout") + || lower.contains("deadline exceeded") + || lower.contains("operation took too long") +} + +fn sandbox_or_approval_signal(text: &str) -> bool { + let lower = text.to_ascii_lowercase(); + lower.contains("sandbox") + || lower.contains("seatbelt") + || lower.contains("landlock") + || lower.contains("seccomp") + || lower.contains("approval") + || lower.contains("denied by user") + || lower.contains("user denied") + || lower.contains("permission denied") + || lower.contains("operation not permitted") + || lower.contains("blocked by policy") +} + +fn network_signal(text: &str) -> bool { + let lower = text.to_ascii_lowercase(); + lower.contains("network") + || lower.contains("dns") + || lower.contains("could not resolve") + || lower.contains("name or service not known") + || lower.contains("temporary failure in name resolution") + || lower.contains("connection refused") + || lower.contains("connection reset") + || lower.contains("connection closed") + || lower.contains("failed to connect") + || lower.contains("tls") + || lower.contains("ssl") + || lower.contains("http 502") + || lower.contains("http 503") + || lower.contains("http 504") + || lower.contains(" 502 ") + || lower.contains(" 503 ") + || lower.contains(" 504 ") + || lower.starts_with("502 ") + || lower.starts_with("503 ") + || lower.starts_with("504 ") + || lower.ends_with(" 502") + || lower.ends_with(" 503") + || lower.ends_with(" 504") + || matches!(lower.as_str(), "502" | "503" | "504") + || lower.contains("curl: (6)") + || lower.contains("curl: (7)") + || lower.contains("curl: (35)") + || lower.contains("curl: (56)") +} + +fn missing_dependency_or_path_signal(text: &str) -> bool { + let lower = text.to_ascii_lowercase(); + lower.contains("command not found") + || lower.contains("no such file or directory") + || lower.contains("enoent") + || lower.contains("not recognized as an internal or external command") + || lower.contains("cannot find the path") + || lower.contains("failed to locate tool") + || lower.contains("module not found") + || lower.contains("modulenotfounderror") + || lower.contains("no module named") + || lower.contains("missing binary") + || lower.contains("missing dependency") +} + +fn sanitize_tool_name(raw: &str) -> String { + let sanitized: String = raw + .chars() + .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.')) + .take(64) + .collect(); + if sanitized.is_empty() { + "tool".to_string() + } else { + sanitized + } +} + +fn redacted_handle(prefix: &str, raw: &str) -> String { + if raw.trim().is_empty() { + return format!("{prefix}_unspecified"); + } + format!("{prefix}_{:016x}", stable_hash(raw)) +} + +fn stable_hash(raw: &str) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325u64; + for byte in raw.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tool<'a>( + source_hint: &'a str, + tool_name: &'a str, + exit_code: Option, + diagnostic: &'a str, + ) -> SessionFailureRecord<'a> { + SessionFailureRecord { + source_hint, + timestamp: Some("2026-05-24T21:00:00Z"), + event: SessionFailureEvent::Tool(ToolFailureRecord { + tool_name, + success: Some(false), + exit_code, + diagnostic: Some(diagnostic), + ..ToolFailureRecord::default() + }), + } + } + + #[test] + fn classifies_synthetic_environment_and_tool_failure_shapes() { + let records = vec![ + tool( + "/Users/hunter/private/session-a.jsonl", + "exec_shell", + Some(101), + "cargo test failed", + ), + tool( + "/Users/hunter/private/session-b.jsonl", + "web_run", + Some(6), + "curl: (6) Could not resolve host: example.invalid", + ), + SessionFailureRecord { + source_hint: "/Users/hunter/private/session-c.jsonl", + timestamp: Some("2026-05-24T21:01:00Z"), + event: SessionFailureEvent::Tool(ToolFailureRecord { + tool_name: "exec_shell", + success: Some(false), + exit_code: Some(1), + sandbox_denied: true, + diagnostic: Some("sandbox-exec blocked file-write"), + ..ToolFailureRecord::default() + }), + }, + tool( + "/Users/hunter/private/session-d.jsonl", + "exec_shell", + Some(127), + "zsh: command not found: cargo-nextest", + ), + SessionFailureRecord { + source_hint: "/Users/hunter/private/session-e.jsonl", + timestamp: Some("2026-05-24T21:02:00Z"), + event: SessionFailureEvent::Tool(ToolFailureRecord { + tool_name: "fetch_url", + success: Some(false), + timed_out: true, + diagnostic: Some("operation timed out after 60s"), + ..ToolFailureRecord::default() + }), + }, + SessionFailureRecord { + source_hint: "/Users/hunter/private/session-f.jsonl", + timestamp: Some("2026-05-24T21:03:00Z"), + event: SessionFailureEvent::TurnStarted { + turn_id: "turn-private-123", + }, + }, + ]; + + let summary = summarize_records(&records); + + assert_eq!(summary.count_for(FailureCategory::CommandExit), 1); + assert_eq!(summary.count_for(FailureCategory::Network), 1); + assert_eq!(summary.count_for(FailureCategory::SandboxApproval), 1); + assert_eq!(summary.count_for(FailureCategory::MissingDependencyPath), 1); + assert_eq!(summary.count_for(FailureCategory::Timeout), 1); + assert_eq!(summary.count_for(FailureCategory::UnclosedTurn), 1); + assert_eq!(summary.environment_suspect_count(), 6); + } + + #[test] + fn specific_environment_signals_beat_generic_nonzero_exit() { + let network = ToolFailureRecord { + tool_name: "exec_shell", + success: Some(false), + exit_code: Some(1), + diagnostic: Some("DNS lookup failed"), + ..ToolFailureRecord::default() + }; + let missing = ToolFailureRecord { + tool_name: "exec_shell", + success: Some(false), + exit_code: Some(127), + diagnostic: Some("No such file or directory"), + ..ToolFailureRecord::default() + }; + let approval = ToolFailureRecord { + tool_name: "edit_file", + success: Some(false), + exit_code: Some(1), + approval_denied: true, + diagnostic: Some("denied by user"), + ..ToolFailureRecord::default() + }; + let timeout = ToolFailureRecord { + tool_name: "web_run", + success: Some(false), + exit_code: Some(124), + diagnostic: Some("deadline exceeded"), + ..ToolFailureRecord::default() + }; + + assert_eq!( + classify_tool_record(&network), + Some(FailureCategory::Network) + ); + assert_eq!( + classify_tool_record(&missing), + Some(FailureCategory::MissingDependencyPath) + ); + assert_eq!( + classify_tool_record(&approval), + Some(FailureCategory::SandboxApproval) + ); + assert_eq!( + classify_tool_record(&timeout), + Some(FailureCategory::Timeout) + ); + } + + #[test] + fn successful_records_and_closed_turns_do_not_emit_failures() { + let records = vec![ + SessionFailureRecord { + source_hint: "session-ok", + timestamp: None, + event: SessionFailureEvent::TurnStarted { turn_id: "turn-1" }, + }, + SessionFailureRecord { + source_hint: "session-ok", + timestamp: None, + event: SessionFailureEvent::Tool(ToolFailureRecord { + tool_name: "exec_shell", + success: Some(true), + exit_code: Some(0), + diagnostic: None, + ..ToolFailureRecord::default() + }), + }, + SessionFailureRecord { + source_hint: "session-ok", + timestamp: None, + event: SessionFailureEvent::TurnCompleted { turn_id: "turn-1" }, + }, + ]; + + let summary = summarize_records(&records); + + assert!(summary.counts.is_empty()); + assert!(summary.evidence.is_empty()); + } + + #[test] + fn summary_uses_redacted_handles_and_does_not_copy_raw_content() { + let records = vec![ + SessionFailureRecord { + source_hint: "/Users/hunter/private/session-secret.jsonl", + timestamp: Some("2026-05-24T21:04:00Z"), + event: SessionFailureEvent::Tool(ToolFailureRecord { + tool_name: "exec shell with spaces", + success: Some(false), + exit_code: Some(1), + diagnostic: Some("fatal output contained sk-test-secret and /private/path"), + output_excerpt: Some("raw transcript text that must stay private"), + ..ToolFailureRecord::default() + }), + }, + SessionFailureRecord { + source_hint: "/Users/hunter/private/session-secret.jsonl", + timestamp: Some("2026-05-24T21:05:00Z"), + event: SessionFailureEvent::TurnStarted { + turn_id: "private-turn-id", + }, + }, + ]; + + let encoded = serde_json::to_string(&summarize_records(&records)).unwrap(); + + assert!(!encoded.contains("/Users/hunter")); + assert!(!encoded.contains("session-secret")); + assert!(!encoded.contains("sk-test-secret")); + assert!(!encoded.contains("raw transcript text")); + assert!(!encoded.contains("private-turn-id")); + assert!(encoded.contains("src_")); + assert!(encoded.contains("turn_")); + assert!(encoded.contains("execshellwithspaces")); + } +} From 17f21a84e1746bcfffdf07ed939b09aa8adbf5df Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 16:38:26 -0500 Subject: [PATCH 106/283] feat(goals): add runtime goal tools and bounded continuation gate (#2199) - Add SharedGoalState and goal tools (goal.rs) to the engine - Sync goal state from host on engine init and goal-objective change - Wire goal_objective through prompt context and system-prompt refresh - Add continuation.md prompt for bounded task continuation - Register goal tools in the tool registry - Add continuation gate tests in engine/tests.rs --- crates/tui/src/core/engine.rs | 71 ++- crates/tui/src/core/engine/tests.rs | 56 +++ crates/tui/src/core/engine/tool_setup.rs | 2 + crates/tui/src/core/engine/turn_loop.rs | 65 +++ crates/tui/src/main.rs | 1 + crates/tui/src/prompts.rs | 4 + crates/tui/src/prompts/continuation.md | 19 + crates/tui/src/runtime_threads.rs | 1 + crates/tui/src/tools/goal.rs | 559 +++++++++++++++++++++++ crates/tui/src/tools/mod.rs | 1 + crates/tui/src/tools/registry.rs | 9 + crates/tui/src/tui/ui.rs | 5 + 12 files changed, 790 insertions(+), 3 deletions(-) create mode 100644 crates/tui/src/prompts/continuation.md create mode 100644 crates/tui/src/tools/goal.rs diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index e81debe9..2ee693fc 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -42,6 +42,7 @@ use crate::models::{ }; use crate::prompts; use crate::seam_manager::{SeamConfig, SeamManager}; +use crate::tools::goal::{SharedGoalState, new_shared_goal_state}; use crate::tools::plan::{SharedPlanState, new_shared_plan_state}; use crate::tools::shell::{SharedShellManager, new_shared_shell_manager}; use crate::tools::spec::RuntimeToolServices; @@ -122,6 +123,8 @@ pub struct EngineConfig { pub todos: SharedTodoList, /// Shared Plan state. pub plan_state: SharedPlanState, + /// Shared runtime goal state for model-visible goal tools. + pub goal_state: SharedGoalState, /// Maximum sub-agent recursion depth (default 3). See /// `SubAgentRuntime::max_spawn_depth`. Override via /// `[runtime] max_spawn_depth = N` in `~/.deepseek/config.toml`. @@ -202,6 +205,7 @@ impl Default for EngineConfig { capacity: CapacityControllerConfig::default(), todos: new_shared_todo_list(), plan_state: new_shared_plan_state(), + goal_state: new_shared_goal_state(), max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, network_policy: None, snapshots_enabled: true, @@ -410,6 +414,10 @@ impl Engine { /// Create a new engine with the given configuration pub fn new(config: EngineConfig, api_config: &Config) -> (Self, EngineHandle) { + if let Some(objective) = normalized_goal_objective(config.goal_objective.as_deref()) { + sync_goal_state_from_host(&config.goal_state, Some(&objective), None, false); + } + let (tx_op, rx_op) = mpsc::channel(32); let (tx_event, rx_event) = mpsc::channel(256); let (tx_approval, rx_approval) = mpsc::channel(64); @@ -441,6 +449,8 @@ impl Engine { // message at request time so file churn does not rewrite this prefix. let user_memory_block = crate::memory::compose_block(config.memory_enabled, &config.memory_path); + let prompt_goal_objective = + goal_objective_for_prompt(config.goal_objective.as_deref(), &config.goal_state); let system_prompt = prompts::system_prompt_for_mode_with_context_skills_session_and_approval( AppMode::Agent, @@ -450,7 +460,7 @@ impl Engine { Some(&config.instructions), prompts::PromptSessionContext { user_memory_block: user_memory_block.as_deref(), - goal_objective: config.goal_objective.as_deref(), + goal_objective: prompt_goal_objective.as_deref(), project_context_pack_enabled: config.project_context_pack_enabled, locale_tag: &config.locale_tag, translation_enabled: config.translation_enabled, @@ -1000,9 +1010,21 @@ impl Engine { let user_msg = self.user_text_message_with_turn_metadata(content); self.session.add_message(user_msg); + let previous_goal_objective = self.config.goal_objective.clone(); + self.session.model = model; self.config.model.clone_from(&self.session.model); - self.config.goal_objective = goal_objective; + self.config.goal_objective = goal_objective.clone(); + if normalized_goal_objective(previous_goal_objective.as_deref()) + != normalized_goal_objective(goal_objective.as_deref()) + { + sync_goal_state_from_host( + &self.config.goal_state, + normalized_goal_objective(goal_objective.as_deref()).as_deref(), + None, + false, + ); + } self.session.reasoning_effort = reasoning_effort; self.session.reasoning_effort_auto = reasoning_effort_auto; self.session.auto_model = auto_model; @@ -1839,6 +1861,10 @@ impl Engine { fn refresh_system_prompt(&mut self, mode: AppMode) { let user_memory_block = crate::memory::compose_block(self.config.memory_enabled, &self.config.memory_path); + let prompt_goal_objective = goal_objective_for_prompt( + self.config.goal_objective.as_deref(), + &self.config.goal_state, + ); let base = prompts::system_prompt_for_mode_with_context_skills_session_and_approval( mode, &self.config.workspace, @@ -1847,7 +1873,7 @@ impl Engine { Some(&self.config.instructions), prompts::PromptSessionContext { user_memory_block: user_memory_block.as_deref(), - goal_objective: self.config.goal_objective.as_deref(), + goal_objective: prompt_goal_objective.as_deref(), project_context_pack_enabled: self.config.project_context_pack_enabled, locale_tag: &self.config.locale_tag, translation_enabled: self.config.translation_enabled, @@ -1906,6 +1932,45 @@ fn system_prompt_hash(prompt: Option<&SystemPrompt>) -> u64 { hasher.finish() } +fn normalized_goal_objective(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn sync_goal_state_from_host( + goal_state: &SharedGoalState, + objective: Option<&str>, + token_budget: Option, + completed: bool, +) { + match goal_state.lock() { + Ok(mut state) => state.sync_from_host(objective, token_budget, completed), + Err(err) => tracing::warn!("goal state lock poisoned while syncing host goal: {err}"), + } +} + +fn goal_objective_for_prompt( + configured_goal: Option<&str>, + goal_state: &SharedGoalState, +) -> Option { + match goal_state.lock() { + Ok(state) => { + if state.objective().is_some() { + return state.is_active().then(|| { + state + .objective() + .expect("checked goal objective") + .to_string() + }); + } + } + Err(err) => tracing::warn!("goal state lock poisoned while building prompt: {err}"), + } + normalized_goal_objective(configured_goal) +} + /// Spawn the engine in a background task pub fn spawn_engine(config: EngineConfig, api_config: &Config) -> EngineHandle { let (engine, handle) = Engine::new(config, api_config); diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 422fdc11..4f732958 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -199,6 +199,37 @@ fn engine_initial_prompt_includes_configured_goal() { assert!(prompt.contains("")); assert!(prompt.contains("Fix goal handoff")); + assert!( + engine + .config + .goal_state + .lock() + .expect("goal lock") + .is_active() + ); +} + +#[test] +fn refresh_system_prompt_uses_runtime_goal_state() { + let (mut engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); + { + let mut goal = engine.config.goal_state.lock().expect("goal lock"); + goal.create("Close the runtime goal loop".to_string(), None); + } + + engine.refresh_system_prompt(AppMode::Agent); + let prompt = match engine.session.system_prompt { + Some(SystemPrompt::Text(text)) => text, + Some(SystemPrompt::Blocks(blocks)) => blocks + .into_iter() + .map(|block| block.text) + .collect::>() + .join("\n"), + None => panic!("expected system prompt"), + }; + + assert!(prompt.contains("")); + assert!(prompt.contains("Close the runtime goal loop")); } #[test] @@ -840,6 +871,9 @@ fn turn_tool_registry_builder_keeps_plan_mode_read_only_for_files() { assert!(!registry.contains("rlm")); assert!(!registry.contains("fim_edit")); assert!(registry.contains("update_plan")); + assert!(registry.contains("create_goal")); + assert!(registry.contains("get_goal")); + assert!(registry.contains("update_goal")); assert!(registry.contains("task_list")); assert!(registry.contains("task_read")); assert!(registry.contains("handle_read")); @@ -892,6 +926,28 @@ fn parent_turn_registry_includes_recall_archive_for_investigative_modes() { } } +#[test] +fn parent_turn_registry_includes_goal_tools_for_all_modes() { + let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); + + for mode in [AppMode::Plan, AppMode::Agent, AppMode::Yolo] { + let registry = engine + .build_turn_tool_registry_builder( + mode, + engine.config.todos.clone(), + engine.config.plan_state.clone(), + ) + .build(engine.build_tool_context(mode, false)); + + for name in ["create_goal", "get_goal", "update_goal"] { + assert!( + registry.contains(name), + "parent {mode:?} registry should expose {name}" + ); + } + } +} + #[test] fn agent_mode_can_build_auto_approved_tool_context() { let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); diff --git a/crates/tui/src/core/engine/tool_setup.rs b/crates/tui/src/core/engine/tool_setup.rs index 2354d6a8..99c5b707 100644 --- a/crates/tui/src/core/engine/tool_setup.rs +++ b/crates/tui/src/core/engine/tool_setup.rs @@ -52,11 +52,13 @@ impl Engine { .with_runtime_read_only_task_tools() .with_todo_tool(todo_list) .with_plan_tool(plan_state) + .with_goal_tools(self.config.goal_state.clone()) } else { ToolRegistryBuilder::new() .with_agent_tools(self.session.allow_shell) .with_todo_tool(todo_list) .with_plan_tool(plan_state) + .with_goal_tools(self.config.goal_state.clone()) }; builder = builder diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 1a1a9104..c0003910 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -39,6 +39,7 @@ impl Engine { } let mut active_tool_names = initial_active_tools(&tool_catalog); let mut loop_guard = LoopGuard::default(); + let mut goal_continuations_this_turn = 0u32; // Transparent stream-retry counter: when the chunked-transfer // connection dies mid-stream and we got nothing useful out of it @@ -1116,6 +1117,21 @@ impl Engine { continue; } + if let Some(continuation) = self + .goal_continuation_message_if_needed( + tool_registry, + &mut goal_continuations_this_turn, + ) + .await + { + self.add_session_message( + self.user_text_message_with_turn_metadata(continuation), + ) + .await; + turn.next_step(); + continue; + } + if thinking_only_no_sendable { let holding_for_subagents = { let running = { @@ -2006,6 +2022,55 @@ impl Engine { (TurnOutcomeStatus::Completed, None) } + async fn goal_continuation_message_if_needed( + &self, + tool_registry: Option<&crate::tools::ToolRegistry>, + continuations_this_turn: &mut u32, + ) -> Option { + let registry = tool_registry?; + if !registry.contains("update_goal") { + return None; + } + + let snapshot = match self.config.goal_state.lock() { + Ok(state) => state.snapshot(), + Err(err) => { + tracing::warn!("goal state lock poisoned during continuation check: {err}"); + return None; + } + }; + + if !snapshot.is_active() { + return None; + } + + let max = crate::tools::goal::MAX_GOAL_CONTINUATIONS_PER_TURN; + if *continuations_this_turn >= max { + let _ = self + .tx_event + .send(Event::status(format!( + "Goal remains active after {max} continuation pass(es); ending turn to avoid a runaway loop." + ))) + .await; + return None; + } + + *continuations_this_turn = (*continuations_this_turn).saturating_add(1); + let _ = self + .tx_event + .send(Event::status(format!( + "Continuing active goal audit ({}/{max})", + *continuations_this_turn + ))) + .await; + + Some(crate::tools::goal::render_continuation_prompt( + &snapshot, + *continuations_this_turn, + max, + )) + } + pub(super) fn messages_with_turn_metadata(&self) -> Vec { // `` is stored on user-text messages when the message is // appended. Do not rewrite historical messages at request time: doing diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 473484dc..478db7ee 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -5180,6 +5180,7 @@ async fn run_exec_agent( capacity: crate::core::capacity::CapacityControllerConfig::from_app_config(config), todos: new_shared_todo_list(), plan_state: new_shared_plan_state(), + goal_state: crate::tools::goal::new_shared_goal_state(), max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, network_policy, snapshots_enabled: config.snapshots_config().enabled, diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index aa69f4f7..ee4e1b5d 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -376,6 +376,10 @@ pub const NEVER_APPROVAL: &str = include_str!("prompts/approvals/never.md"); /// model knows the format to use when writing `.deepseek/handoff.md`. pub const COMPACT_TEMPLATE: &str = include_str!("prompts/compact.md"); +/// Goal continuation audit template — injected by the engine when a runtime +/// goal is active and the assistant tries to end a turn without closing it. +pub const GOAL_CONTINUATION_PROMPT: &str = include_str!("prompts/continuation.md"); + /// Memory hygiene guidance — appended to the system prompt only when the /// session has a non-empty user-memory block. Steers the model toward /// writing durable memories as declarative facts ("User prefers concise diff --git a/crates/tui/src/prompts/continuation.md b/crates/tui/src/prompts/continuation.md new file mode 100644 index 00000000..492cb1a6 --- /dev/null +++ b/crates/tui/src/prompts/continuation.md @@ -0,0 +1,19 @@ +## Goal Continuation + +You are working toward an active session goal. Your task now is to make concrete +progress toward the objective and audit whether the full goal is complete. + +Completion is unproven until you verify it against current-state evidence: + +1. Derive the concrete requirements from the goal and the latest user + instructions. +2. Inspect authoritative evidence for each requirement: files, command output, + tests, runtime behavior, issue or PR state, rendered artifacts, or other + current sources. +3. Treat uncertain or indirect evidence as not complete. Continue work or gather + stronger evidence. +4. Only when the full objective is satisfied, call `update_goal` with + `status: "complete"` and concise evidence. + +If the goal cannot continue because of a real blocker, call `update_goal` with +`status: "blocked"` and explain the blocker. Otherwise continue making progress. diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 9cd65087..943cf5fc 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1952,6 +1952,7 @@ impl RuntimeThreadManager { ), todos: new_shared_todo_list(), plan_state: new_shared_plan_state(), + goal_state: crate::tools::goal::new_shared_goal_state(), max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, network_policy, snapshots_enabled: self.config.snapshots_config().enabled, diff --git a/crates/tui/src/tools/goal.rs b/crates/tui/src/tools/goal.rs new file mode 100644 index 00000000..5ce3c4d1 --- /dev/null +++ b/crates/tui/src/tools/goal.rs @@ -0,0 +1,559 @@ +//! Goal tools for the model-visible LLM-as-judge loop. +//! +//! The TUI already has a `/goal` command and passes its objective into the +//! engine prompt. This module keeps the runtime slice separate: a small +//! session-scoped state object plus tools the model can use to inspect and +//! close out that state. + +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use async_trait::async_trait; +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::tools::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, required_str, +}; + +/// Maximum number of automatic goal-continuation prompt injections in one +/// engine turn. This prevents a missing `update_goal` call from becoming an +/// unbounded local loop. +pub const MAX_GOAL_CONTINUATIONS_PER_TURN: u32 = 3; + +/// Shared reference to the current runtime goal. +pub type SharedGoalState = Arc>; + +/// Create an empty shared goal state. +#[must_use] +pub fn new_shared_goal_state() -> SharedGoalState { + Arc::new(Mutex::new(GoalState::default())) +} + +/// Create shared state seeded from the existing `/goal` surface. +#[must_use] +pub fn new_shared_goal_state_from_host( + objective: Option, + token_budget: Option, + completed: bool, +) -> SharedGoalState { + let mut state = GoalState::default(); + state.sync_from_host(objective.as_deref(), token_budget, completed); + Arc::new(Mutex::new(state)) +} + +/// Runtime status for a goal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GoalStatus { + Active, + Complete, + Blocked, +} + +impl GoalStatus { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Complete => "complete", + Self::Blocked => "blocked", + } + } +} + +/// Session-local goal state. `Instant` stays runtime-only; snapshots expose +/// elapsed seconds so tool output remains serializable and stable. +#[derive(Debug, Clone, Default)] +pub struct GoalState { + objective: Option, + token_budget: Option, + status: Option, + started_at: Option, + finished_at: Option, + evidence: Option, + blocker: Option, +} + +impl GoalState { + #[must_use] + pub fn objective(&self) -> Option<&str> { + self.objective.as_deref() + } + + #[must_use] + pub fn is_active(&self) -> bool { + self.objective.is_some() && self.status == Some(GoalStatus::Active) + } + + pub fn sync_from_host( + &mut self, + objective: Option<&str>, + token_budget: Option, + completed: bool, + ) { + let objective = objective.map(str::trim).filter(|value| !value.is_empty()); + match objective { + Some(objective) => { + let changed = self.objective.as_deref() != Some(objective); + if changed { + self.objective = Some(objective.to_string()); + self.token_budget = token_budget; + self.started_at = Some(Instant::now()); + self.evidence = None; + self.blocker = None; + } else if token_budget.is_some() { + self.token_budget = token_budget; + } + + if changed || self.status.is_none() { + self.status = Some(if completed { + GoalStatus::Complete + } else { + GoalStatus::Active + }); + self.finished_at = completed.then(Instant::now); + } + } + None => self.clear(), + } + } + + pub fn create(&mut self, objective: String, token_budget: Option) { + self.objective = Some(objective); + self.token_budget = token_budget; + self.status = Some(GoalStatus::Active); + self.started_at = Some(Instant::now()); + self.finished_at = None; + self.evidence = None; + self.blocker = None; + } + + pub fn resume(&mut self, objective: Option) -> Result<(), &'static str> { + if let Some(objective) = objective { + self.create(objective, self.token_budget); + return Ok(()); + } + if self.objective.is_none() { + return Err("No goal exists to resume."); + } + self.status = Some(GoalStatus::Active); + self.finished_at = None; + self.evidence = None; + self.blocker = None; + Ok(()) + } + + pub fn mark_complete(&mut self, evidence: String) -> Result<(), &'static str> { + if self.objective.is_none() { + return Err("No active goal exists to complete."); + } + self.status = Some(GoalStatus::Complete); + self.finished_at = Some(Instant::now()); + self.evidence = Some(evidence); + self.blocker = None; + Ok(()) + } + + pub fn mark_blocked(&mut self, blocker: String) -> Result<(), &'static str> { + if self.objective.is_none() { + return Err("No active goal exists to block."); + } + self.status = Some(GoalStatus::Blocked); + self.finished_at = Some(Instant::now()); + self.blocker = Some(blocker); + Ok(()) + } + + pub fn clear(&mut self) { + *self = Self::default(); + } + + #[must_use] + pub fn snapshot(&self) -> GoalSnapshot { + GoalSnapshot { + objective: self.objective.clone(), + status: self + .status + .map(GoalStatus::as_str) + .unwrap_or("none") + .to_string(), + token_budget: self.token_budget, + elapsed_seconds: self.started_at.map(|started| started.elapsed().as_secs()), + evidence: self.evidence.clone(), + blocker: self.blocker.clone(), + } + } +} + +/// Serializable tool output and prompt input for the current goal. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct GoalSnapshot { + pub objective: Option, + pub status: String, + pub token_budget: Option, + pub elapsed_seconds: Option, + pub evidence: Option, + pub blocker: Option, +} + +impl GoalSnapshot { + #[must_use] + pub fn is_active(&self) -> bool { + self.objective.is_some() && self.status == GoalStatus::Active.as_str() + } +} + +/// Render the bounded continuation prompt injected when a goal is still active +/// after an assistant message has no tool calls. +#[must_use] +pub fn render_continuation_prompt( + snapshot: &GoalSnapshot, + continuation_index: u32, + max_continuations: u32, +) -> String { + let goal_json = serde_json::to_string_pretty(snapshot).unwrap_or_else(|_| "{}".to_string()); + format!( + "{}\n\n## Active Goal State\n\n```json\n{}\n```\n\nContinuation pass: {}/{}.\nIf the goal is complete, call `update_goal` with `status: \"complete\"` and concrete evidence. If it is blocked, call `update_goal` with `status: \"blocked\"` and the blocker. Otherwise continue making progress toward the objective.", + crate::prompts::GOAL_CONTINUATION_PROMPT.trim(), + goal_json, + continuation_index, + max_continuations, + ) +} + +fn lock_goal_state( + state: &SharedGoalState, +) -> Result, ToolError> { + state + .lock() + .map_err(|_| ToolError::execution_failed("goal state lock poisoned")) +} + +fn parse_token_budget(input: &Value) -> Result, ToolError> { + let Some(raw) = input.get("token_budget") else { + return Ok(None); + }; + if raw.is_null() { + return Ok(None); + } + let Some(value) = raw.as_u64() else { + return Err(ToolError::invalid_input( + "token_budget must be a non-negative integer", + )); + }; + u32::try_from(value) + .map(Some) + .map_err(|_| ToolError::invalid_input("token_budget is too large")) +} + +fn json_result(snapshot: &GoalSnapshot) -> Result { + ToolResult::json(snapshot).map_err(|err| ToolError::execution_failed(err.to_string())) +} + +pub struct CreateGoalTool { + goal_state: SharedGoalState, +} + +impl CreateGoalTool { + #[must_use] + pub fn new(goal_state: SharedGoalState) -> Self { + Self { goal_state } + } +} + +#[async_trait] +impl ToolSpec for CreateGoalTool { + fn name(&self) -> &'static str { + "create_goal" + } + + fn description(&self) -> &'static str { + "Create or replace the current runtime goal. Use this when the user asks for a persistent goal that should be audited before the turn is allowed to finish." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The full objective to pursue. Keep the complete user goal, not a shortened one-turn version." + }, + "token_budget": { + "type": "integer", + "minimum": 0, + "description": "Optional soft token budget for the goal." + } + }, + "required": ["objective"], + "additionalProperties": false + }) + } + + fn capabilities(&self) -> Vec { + Vec::new() + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + let objective = required_str(&input, "objective")?.trim().to_string(); + if objective.is_empty() { + return Err(ToolError::invalid_input("objective cannot be empty")); + } + let token_budget = parse_token_budget(&input)?; + let snapshot = { + let mut state = lock_goal_state(&self.goal_state)?; + state.create(objective, token_budget); + state.snapshot() + }; + json_result(&snapshot) + } +} + +pub struct GetGoalTool { + goal_state: SharedGoalState, +} + +impl GetGoalTool { + #[must_use] + pub fn new(goal_state: SharedGoalState) -> Self { + Self { goal_state } + } +} + +#[async_trait] +impl ToolSpec for GetGoalTool { + fn name(&self) -> &'static str { + "get_goal" + } + + fn description(&self) -> &'static str { + "Inspect the current runtime goal state, including objective, status, token budget, elapsed time, evidence, and blocker." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ReadOnly] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + fn supports_parallel(&self) -> bool { + true + } + + async fn execute( + &self, + _input: Value, + _context: &ToolContext, + ) -> Result { + let snapshot = { + let state = lock_goal_state(&self.goal_state)?; + state.snapshot() + }; + json_result(&snapshot) + } +} + +pub struct UpdateGoalTool { + goal_state: SharedGoalState, +} + +impl UpdateGoalTool { + #[must_use] + pub fn new(goal_state: SharedGoalState) -> Self { + Self { goal_state } + } +} + +#[async_trait] +impl ToolSpec for UpdateGoalTool { + fn name(&self) -> &'static str { + "update_goal" + } + + fn description(&self) -> &'static str { + "Update the runtime goal. This is the LLM-as-judge completion gate: only mark complete when the objective has been verified against concrete current-state evidence." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["active", "complete", "blocked"], + "description": "Use complete only when the goal is fully satisfied; blocked when meaningful progress cannot continue; active to resume or revise the objective." + }, + "evidence": { + "type": "string", + "description": "Required when status is complete. Briefly cite the proof that the goal is done." + }, + "blocker": { + "type": "string", + "description": "Required when status is blocked. Explain the condition preventing progress." + }, + "objective": { + "type": "string", + "description": "Optional replacement objective when status is active." + } + }, + "required": ["status"], + "additionalProperties": false + }) + } + + fn capabilities(&self) -> Vec { + Vec::new() + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + let status = required_str(&input, "status")?.trim().to_ascii_lowercase(); + let snapshot = { + let mut state = lock_goal_state(&self.goal_state)?; + match status.as_str() { + "complete" => { + let evidence = input + .get("evidence") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or_default() + .to_string(); + if evidence.is_empty() { + return Err(ToolError::invalid_input( + "evidence is required when status is complete", + )); + } + state + .mark_complete(evidence) + .map_err(ToolError::invalid_input)?; + } + "blocked" => { + let blocker = input + .get("blocker") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or_default() + .to_string(); + if blocker.is_empty() { + return Err(ToolError::invalid_input( + "blocker is required when status is blocked", + )); + } + state + .mark_blocked(blocker) + .map_err(ToolError::invalid_input)?; + } + "active" => { + let objective = input + .get("objective") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + state.resume(objective).map_err(ToolError::invalid_input)?; + } + other => { + return Err(ToolError::invalid_input(format!( + "unsupported goal status '{other}'" + ))); + } + } + state.snapshot() + }; + json_result(&snapshot) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[tokio::test] + async fn create_get_and_complete_goal() { + let state = new_shared_goal_state(); + let ctx = ToolContext::new("."); + + let create = CreateGoalTool::new(state.clone()); + let created = create + .execute( + json!({ + "objective": "ship the runtime slice", + "token_budget": 1200 + }), + &ctx, + ) + .await + .expect("create goal"); + assert!(created.success); + assert!(created.content.contains("\"status\": \"active\"")); + + let get = GetGoalTool::new(state.clone()); + let current = get.execute(json!({}), &ctx).await.expect("get goal"); + assert!(current.content.contains("ship the runtime slice")); + assert!(current.content.contains("\"token_budget\": 1200")); + + let update = UpdateGoalTool::new(state.clone()); + let completed = update + .execute( + json!({ + "status": "complete", + "evidence": "focused tests passed" + }), + &ctx, + ) + .await + .expect("complete goal"); + assert!(completed.content.contains("\"status\": \"complete\"")); + assert!(completed.content.contains("focused tests passed")); + assert!(!state.lock().expect("goal lock").is_active()); + } + + #[tokio::test] + async fn update_goal_requires_completion_evidence() { + let state = + new_shared_goal_state_from_host(Some("prove completion".to_string()), None, false); + let update = UpdateGoalTool::new(state); + let err = update + .execute(json!({"status": "complete"}), &ToolContext::new(".")) + .await + .expect_err("missing evidence should fail"); + + assert!(err.to_string().contains("evidence is required")); + } + + #[test] + fn continuation_prompt_includes_bound_and_goal_state() { + let snapshot = GoalSnapshot { + objective: Some("finish issue 2199".to_string()), + status: "active".to_string(), + token_budget: None, + elapsed_seconds: Some(5), + evidence: None, + blocker: None, + }; + + let prompt = render_continuation_prompt(&snapshot, 2, 3); + assert!(prompt.contains("Goal Continuation")); + assert!(prompt.contains("finish issue 2199")); + assert!(prompt.contains("Continuation pass: 2/3")); + } +} diff --git a/crates/tui/src/tools/mod.rs b/crates/tui/src/tools/mod.rs index aea1cc5f..e5427065 100644 --- a/crates/tui/src/tools/mod.rs +++ b/crates/tui/src/tools/mod.rs @@ -24,6 +24,7 @@ pub mod fim; pub mod git; pub mod git_history; pub mod github; +pub mod goal; pub mod handle; pub mod image_ocr; pub mod js_execution; diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index 5254de70..5a437abf 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -844,6 +844,15 @@ impl ToolRegistryBuilder { self.with_tool(Arc::new(UpdatePlanTool::new(plan_state))) } + /// Include runtime goal tools (`create_goal`, `get_goal`, `update_goal`). + #[must_use] + pub fn with_goal_tools(self, goal_state: super::goal::SharedGoalState) -> Self { + use super::goal::{CreateGoalTool, GetGoalTool, UpdateGoalTool}; + self.with_tool(Arc::new(CreateGoalTool::new(goal_state.clone()))) + .with_tool(Arc::new(GetGoalTool::new(goal_state.clone()))) + .with_tool(Arc::new(UpdateGoalTool::new(goal_state))) + } + /// Include sub-agent management tools. #[must_use] pub fn with_subagent_tools( diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 2ba52ef9..295baa22 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -700,6 +700,11 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { capacity: crate::core::capacity::CapacityControllerConfig::from_app_config(config), todos: app.todos.clone(), plan_state: app.plan_state.clone(), + goal_state: crate::tools::goal::new_shared_goal_state_from_host( + app.goal.goal_objective.clone(), + app.goal.goal_token_budget, + app.goal.goal_completed, + ), max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, network_policy: config.network.clone().map(|toml_cfg| { crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime()) From 60c1b6619cfdba6c714344a42b062f3ee55c5811 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 16:39:28 -0500 Subject: [PATCH 107/283] style: rustfmt cleanups and minor formatting fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Line-wrap long function signatures and format arguments - Fix bracket placement for early returns (consistent style) - Use [!] instead of [✓] for network-denied skill sync - Fix copy-selection ordering: clear after success, not always --- crates/cli/src/update.rs | 28 ++++++++++++++++++++++---- crates/config/src/lib.rs | 10 ++------- crates/tui/src/commands/session.rs | 5 ++++- crates/tui/src/commands/skills.rs | 2 +- crates/tui/src/config.rs | 20 +++++++++++++----- crates/tui/src/core/capacity_memory.rs | 3 +-- crates/tui/src/cycle_manager.rs | 13 ++++++------ crates/tui/src/tools/truncate.rs | 4 +++- crates/tui/src/tui/mouse_ui.rs | 2 +- crates/tui/src/tui/onboarding/mod.rs | 4 +++- crates/tui/src/tui/ui.rs | 6 +++++- 11 files changed, 65 insertions(+), 32 deletions(-) diff --git a/crates/cli/src/update.rs b/crates/cli/src/update.rs index 5b1dce53..2ab35ef1 100644 --- a/crates/cli/src/update.rs +++ b/crates/cli/src/update.rs @@ -351,8 +351,8 @@ fn update_http_client() -> Result { /// Fetch the latest release metadata from GitHub. fn fetch_latest_release(channel: ReleaseChannel) -> Result { - if let Some(base_url) = release_base_url_from_env() { - let version = update_version_from_env().unwrap_or_else(|| env!("CARGO_PKG_VERSION").into()); + let version = update_version_from_env().unwrap_or_else(|| env!("CARGO_PKG_VERSION").into()); + if let Some(base_url) = release_base_url_from_env(&version) { return Ok(FetchedRelease { release: release_from_mirror_base_url( &base_url, @@ -373,7 +373,7 @@ fn fetch_latest_release(channel: ReleaseChannel) -> Result { }) } -fn release_base_url_from_env() -> Option { +fn release_base_url_from_env(version: &str) -> Option { // Check canonical env first, then legacy envs for env_name in [ RELEASE_BASE_URL_ENV, @@ -389,11 +389,19 @@ fn release_base_url_from_env() -> Option { } // Auto-detect CNB mirror when CODEWHALE_USE_CNB_MIRROR is set if std::env::var(CNB_MIRROR_ENV).is_ok() { - return Some(CNB_RELEASE_ASSET_BASE.to_string()); + return Some(cnb_release_base_url(version)); } None } +fn cnb_release_base_url(version: &str) -> String { + format!( + "{}/v{}", + CNB_RELEASE_ASSET_BASE.trim_end_matches('/'), + version.trim_start_matches('v') + ) +} + fn update_version_from_env() -> Option { std::env::var(UPDATE_VERSION_ENV) .ok() @@ -993,6 +1001,18 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win ); } + #[test] + fn cnb_release_base_url_includes_tag_directory() { + assert_eq!( + cnb_release_base_url("0.8.47"), + "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47" + ); + assert_eq!( + cnb_release_base_url("v0.8.47"), + "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47" + ); + } + #[test] fn stable_update_is_needed_only_when_latest_is_newer() { assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.46").unwrap()); diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index ffc78e66..bb2c339b 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1624,10 +1624,7 @@ pub fn ensure_state_dir(subdir: &str) -> Result { /// Returns `(true, path)` when the primary `.codewhale/` path is used, /// `(false, path)` for the legacy fallback. The boolean helps callers /// emit a deprecation notice on legacy paths. -pub fn resolve_project_state_dir( - workspace: &Path, - subdir: &str, -) -> (bool, PathBuf) { +pub fn resolve_project_state_dir(workspace: &Path, subdir: &str) -> (bool, PathBuf) { let primary = workspace.join(CODEWHALE_APP_DIR).join(subdir); if primary.exists() { return (true, primary); @@ -1638,10 +1635,7 @@ pub fn resolve_project_state_dir( /// Ensure a project-local state subdirectory exists under `.codewhale/`, /// creating it if necessary. Returns the directory path. -pub fn ensure_project_state_dir( - workspace: &Path, - subdir: &str, -) -> Result { +pub fn ensure_project_state_dir(workspace: &Path, subdir: &str) -> Result { let dir = workspace.join(CODEWHALE_APP_DIR).join(subdir); std::fs::create_dir_all(&dir) .with_context(|| format!("failed to create {}/", dir.display()))?; diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index 316e8c6c..80535bbd 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -513,7 +513,10 @@ mod tests { Vec::new() }; // Session should be saved to the managed dir, not the workspace root. - assert!(!entries.is_empty(), "expected session file in {sessions_dir:?}, got none; msg: {msg}"); + assert!( + !entries.is_empty(), + "expected session file in {sessions_dir:?}, got none; msg: {msg}" + ); } #[test] diff --git a/crates/tui/src/commands/skills.rs b/crates/tui/src/commands/skills.rs index 9dc7fbf7..a8a4997f 100644 --- a/crates/tui/src/commands/skills.rs +++ b/crates/tui/src/commands/skills.rs @@ -441,7 +441,7 @@ fn sync_skills(app: &mut App) -> CommandResult { } SkillSyncOutcome::Denied { name, host } => { failed += 1; - let _ = writeln!(out, " [✓] {name} — network denied ({host})"); + let _ = writeln!(out, " [!] {name} — network denied ({host})"); } SkillSyncOutcome::NeedsApproval { name, host } => { failed += 1; diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 55d46871..44bfd200 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -2371,7 +2371,9 @@ fn default_managed_config_path() -> Option { { effective_home_dir().map(|home| { let primary = home.join(".codewhale").join("managed_config.toml"); - if primary.exists() { return primary; } + if primary.exists() { + return primary; + } home.join(".deepseek").join("managed_config.toml") }) } @@ -2386,7 +2388,9 @@ fn default_requirements_path() -> Option { { effective_home_dir().map(|home| { let primary = home.join(".codewhale").join("requirements.toml"); - if primary.exists() { return primary; } + if primary.exists() { + return primary; + } home.join(".deepseek").join("requirements.toml") }) } @@ -2415,7 +2419,9 @@ fn default_skills_dir() -> Option { fn default_mcp_config_path() -> Option { effective_home_dir().map(|home| { let primary = home.join(".codewhale").join("mcp.json"); - if primary.exists() { return primary; } + if primary.exists() { + return primary; + } home.join(".deepseek").join("mcp.json") }) } @@ -2423,7 +2429,9 @@ fn default_mcp_config_path() -> Option { fn default_notes_path() -> Option { effective_home_dir().map(|home| { let primary = home.join(".codewhale").join("notes.txt"); - if primary.exists() { return primary; } + if primary.exists() { + return primary; + } home.join(".deepseek").join("notes.txt") }) } @@ -2431,7 +2439,9 @@ fn default_notes_path() -> Option { fn default_memory_path() -> Option { effective_home_dir().map(|home| { let primary = home.join(".codewhale").join("memory.md"); - if primary.exists() { return primary; } + if primary.exists() { + return primary; + } home.join(".deepseek").join("memory.md") }) } diff --git a/crates/tui/src/core/capacity_memory.rs b/crates/tui/src/core/capacity_memory.rs index ab598512..0d22e4df 100644 --- a/crates/tui/src/core/capacity_memory.rs +++ b/crates/tui/src/core/capacity_memory.rs @@ -64,8 +64,7 @@ fn capacity_memory_dirs() -> Vec { dirs.push(home.join(".deepseek").join("memory")); } - let cwd = std::env::current_dir() - .unwrap_or_else(|_| PathBuf::from(".")); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let primary_cwd = cwd.join(".codewhale").join("memory"); if primary_cwd.exists() { dirs.push(primary_cwd); diff --git a/crates/tui/src/cycle_manager.rs b/crates/tui/src/cycle_manager.rs index 6817811e..c7315053 100644 --- a/crates/tui/src/cycle_manager.rs +++ b/crates/tui/src/cycle_manager.rs @@ -466,13 +466,12 @@ pub struct CycleArchiveHeader { /// Resolve the on-disk archive directory: `~/.codewhale/sessions//cycles` /// (or legacy `~/.deepseek/sessions//cycles`). fn archive_dir_for(session_id: &str) -> Result { - let sessions = codewhale_config::resolve_state_dir("sessions") - .unwrap_or_else(|_| { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".deepseek") - .join("sessions") - }); + let sessions = codewhale_config::resolve_state_dir("sessions").unwrap_or_else(|_| { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".deepseek") + .join("sessions") + }); Ok(sessions.join(session_id).join("cycles")) } diff --git a/crates/tui/src/tools/truncate.rs b/crates/tui/src/tools/truncate.rs index 6c8d6e69..4de0a540 100644 --- a/crates/tui/src/tools/truncate.rs +++ b/crates/tui/src/tools/truncate.rs @@ -82,7 +82,9 @@ pub fn spillover_root() -> Option { } // Prefer .codewhale, fall back to .deepseek - let primary = dirs::home_dir()?.join(".codewhale").join(SPILLOVER_DIR_NAME); + let primary = dirs::home_dir()? + .join(".codewhale") + .join(SPILLOVER_DIR_NAME); if primary.exists() { return Some(primary); } diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index 92278bb6..a22b2b61 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -714,10 +714,10 @@ pub(crate) fn copy_active_selection(app: &mut App) { if !sel.is_empty() { if app.clipboard.write_text(&sel).is_ok() { app.status_message = Some("Selection copied".to_string()); + app.clear_selection(); } else { app.status_message = Some("Copy failed".to_string()); } - app.clear_selection(); return; } if !app.viewport.transcript_selection.is_active() { diff --git a/crates/tui/src/tui/onboarding/mod.rs b/crates/tui/src/tui/onboarding/mod.rs index 2967cab6..a1cce682 100644 --- a/crates/tui/src/tui/onboarding/mod.rs +++ b/crates/tui/src/tui/onboarding/mod.rs @@ -130,7 +130,9 @@ pub fn tips_lines(app: &App) -> Vec> { pub fn default_marker_path() -> Option { dirs::home_dir().map(|home| { let primary = home.join(".codewhale").join(".onboarded"); - if primary.exists() { return primary; } + if primary.exists() { + return primary; + } home.join(".deepseek").join(".onboarded") }) } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index c0f1dad4..83c755b8 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2972,7 +2972,11 @@ async fn run_event_loop( let sel = app.selected_text(); if !sel.is_empty() { if app.clipboard.write_text(&sel).is_ok() { - app.push_status_toast("Copied to clipboard", StatusToastLevel::Info, None); + app.push_status_toast( + "Copied to clipboard", + StatusToastLevel::Info, + None, + ); app.clear_selection(); } else { app.push_status_toast("Copy failed", StatusToastLevel::Error, None); From 74878dcd300903e95491e04e743e649503a4a1d8 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 16:39:39 -0500 Subject: [PATCH 108/283] refactor(tools): replace Semaphore with RwLock for parallel-safe tool execution - Use OwnedRwLockReadGuard for parallel-safe tools, OwnedRwLockWriteGuard for serial - Add TOOL_EXECUTION_LOCK_HELD task-local for reentrancy detection - Add BlockingHandler test harness and parallel-vs-serial concurrency tests --- crates/tools/src/lib.rs | 62 ++++++--- crates/tools/tests/parity_tools.rs | 208 ++++++++++++++++++++++++++++- 2 files changed, 248 insertions(+), 22 deletions(-) diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 050b840f..b0ffc55b 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -8,7 +8,11 @@ use async_trait::async_trait; use codewhale_protocol::{ToolKind, ToolOutput, ToolPayload}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use tokio::sync::Semaphore; +use tokio::sync::{OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock}; + +tokio::task_local! { + static TOOL_EXECUTION_LOCK_HELD: (); +} /// Capabilities that a tool may have or require. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -311,15 +315,36 @@ pub trait ToolHandler: Send + Sync { #[derive(Debug)] pub struct ToolCallRuntime { - /// Serialise non-parallel tool executions. Capacity 1 ensures at most one - /// serial tool runs at a time, and blocks parallel tools while it runs. - serial_semaphore: Arc, + /// Preserve read/write tool execution semantics: parallel-safe tools may + /// overlap, while serial tools run exclusively. + execution_lock: Arc>, } impl Default for ToolCallRuntime { fn default() -> Self { Self { - serial_semaphore: Arc::new(Semaphore::new(1)), + execution_lock: Arc::new(RwLock::new(())), + } + } +} + +#[derive(Debug)] +enum ToolExecutionGuard { + Parallel(#[allow(dead_code)] OwnedRwLockReadGuard<()>), + Serial(#[allow(dead_code)] OwnedRwLockWriteGuard<()>), + Reentrant, +} + +impl ToolCallRuntime { + async fn acquire(&self, supports_parallel: bool) -> ToolExecutionGuard { + if TOOL_EXECUTION_LOCK_HELD.try_with(|_| ()).is_ok() { + return ToolExecutionGuard::Reentrant; + } + + if supports_parallel { + ToolExecutionGuard::Parallel(self.execution_lock.clone().read_owned().await) + } else { + ToolExecutionGuard::Serial(self.execution_lock.clone().write_owned().await) } } } @@ -389,22 +414,17 @@ impl ToolRegistry { source: call.source, }; - if configured.supports_parallel_tool_calls { - // Parallel tools wait for any in-flight serial tool to finish, - // but do not hold the permit so other parallel tools may run concurrently. - drop(self.runtime.serial_semaphore.acquire().await - .map_err(|_| FunctionCallError::Cancelled { name: call.name })?); - self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation) - .await - } else { - // Serial tools hold the semaphore for the full execution duration, - // preventing other serial AND parallel tools from starting. - let _permit = self.runtime.serial_semaphore.acquire().await - .map_err(|_| FunctionCallError::Cancelled { name: call.name })?; - self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation) - .await - // _permit dropped here, releasing the semaphore. - } + let _guard = self + .runtime + .acquire(configured.supports_parallel_tool_calls) + .await; + + TOOL_EXECUTION_LOCK_HELD + .scope( + (), + self.execute_with_timeout(handler, configured.spec.timeout_ms, invocation), + ) + .await } async fn execute_with_timeout( diff --git a/crates/tools/tests/parity_tools.rs b/crates/tools/tests/parity_tools.rs index fb08753b..ef525ba4 100644 --- a/crates/tools/tests/parity_tools.rs +++ b/crates/tools/tests/parity_tools.rs @@ -1,4 +1,5 @@ -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; use async_trait::async_trait; use codewhale_protocol::{ToolKind, ToolOutput, ToolPayload}; @@ -6,6 +7,7 @@ use codewhale_tools::{ ToolCall, ToolCallSource, ToolHandler, ToolInvocation, ToolRegistry, ToolSpec, }; use serde_json::json; +use tokio::sync::Notify; struct EchoHandler; @@ -33,6 +35,64 @@ impl ToolHandler for EchoHandler { } } +struct BlockingHandler { + started: Arc, + release: Arc, +} + +#[async_trait] +impl ToolHandler for BlockingHandler { + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle( + &self, + invocation: ToolInvocation, + ) -> std::result::Result { + self.started.notify_waiters(); + self.release.notified().await; + Ok(ToolOutput::Function { + body: Some(json!({ + "tool": invocation.tool_name, + "call_id": invocation.call_id + })), + success: true, + }) + } +} + +struct ReentrantHandler { + registry: Arc>>, +} + +#[async_trait] +impl ToolHandler for ReentrantHandler { + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle( + &self, + _invocation: ToolInvocation, + ) -> std::result::Result { + let registry = self.registry.get().expect("registry initialized").clone(); + registry + .dispatch( + ToolCall { + name: "inner".to_string(), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + source: ToolCallSource::Direct, + raw_tool_call_id: Some("inner-call".to_string()), + }, + true, + ) + .await + } +} + #[tokio::test] async fn dispatches_function_tool_with_parallel_flag() { let mut registry = ToolRegistry::default(); @@ -68,3 +128,149 @@ async fn dispatches_function_tool_with_parallel_flag() { other => panic!("unexpected output: {other:?}"), } } + +#[tokio::test] +async fn serial_tool_waits_for_running_parallel_tool() { + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let mut registry = ToolRegistry::default(); + registry + .register( + ToolSpec { + name: "slow_read".to_string(), + input_schema: json!({"type":"object"}), + output_schema: json!({"type":"object"}), + supports_parallel_tool_calls: true, + timeout_ms: Some(1000), + }, + Arc::new(BlockingHandler { + started: started.clone(), + release: release.clone(), + }), + ) + .expect("register slow read"); + registry + .register( + ToolSpec { + name: "serial".to_string(), + input_schema: json!({"type":"object"}), + output_schema: json!({"type":"object"}), + supports_parallel_tool_calls: false, + timeout_ms: Some(1000), + }, + Arc::new(EchoHandler), + ) + .expect("register serial"); + + let registry = Arc::new(registry); + let started_wait = started.notified(); + let parallel_registry = registry.clone(); + let parallel = tokio::spawn(async move { + parallel_registry + .dispatch( + ToolCall { + name: "slow_read".to_string(), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + source: ToolCallSource::Direct, + raw_tool_call_id: Some("parallel-call".to_string()), + }, + true, + ) + .await + }); + tokio::time::timeout(Duration::from_secs(1), started_wait) + .await + .expect("parallel tool started"); + + let serial_registry = registry.clone(); + let mut serial = tokio::spawn(async move { + serial_registry + .dispatch( + ToolCall { + name: "serial".to_string(), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + source: ToolCallSource::Direct, + raw_tool_call_id: Some("serial-call".to_string()), + }, + true, + ) + .await + }); + + tokio::select! { + _ = &mut serial => panic!("serial tool overlapped a running parallel tool"), + () = tokio::time::sleep(Duration::from_millis(50)) => {} + } + + release.notify_waiters(); + serial + .await + .expect("serial task panicked") + .expect("serial ran"); + parallel + .await + .expect("parallel task panicked") + .expect("parallel ran"); +} + +#[tokio::test] +async fn serial_tool_can_reenter_registry_without_deadlock() { + let registry_cell = Arc::new(OnceLock::new()); + let mut registry = ToolRegistry::default(); + registry + .register( + ToolSpec { + name: "outer".to_string(), + input_schema: json!({"type":"object"}), + output_schema: json!({"type":"object"}), + supports_parallel_tool_calls: false, + timeout_ms: Some(1000), + }, + Arc::new(ReentrantHandler { + registry: registry_cell.clone(), + }), + ) + .expect("register outer"); + registry + .register( + ToolSpec { + name: "inner".to_string(), + input_schema: json!({"type":"object"}), + output_schema: json!({"type":"object"}), + supports_parallel_tool_calls: false, + timeout_ms: Some(1000), + }, + Arc::new(EchoHandler), + ) + .expect("register inner"); + + let registry = Arc::new(registry); + assert!(registry_cell.set(registry.clone()).is_ok()); + + let output = tokio::time::timeout( + Duration::from_secs(1), + registry.dispatch( + ToolCall { + name: "outer".to_string(), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + source: ToolCallSource::Direct, + raw_tool_call_id: Some("outer-call".to_string()), + }, + true, + ), + ) + .await + .expect("outer dispatch timed out") + .expect("outer dispatch failed"); + + match output { + ToolOutput::Function { success, .. } => assert!(success), + other => panic!("unexpected output: {other:?}"), + } +} From 92c8dbc7cec77d0cc399a9353afc3fca014a43a5 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 16:39:39 -0500 Subject: [PATCH 109/283] fix(composer): use wrap_input_lines_for_mouse, clamp selection to input length - Replace visible_line_char_ranges with wrap_input_lines_for_mouse for accurate mouse selection - Clamp selection_anchor and cursor_position to char_count - Clear selection on history navigation to prevent stale highlights - Add test for history-navigation-clears-stale-selection --- crates/tui/src/tui/app.rs | 24 +++++++++++- crates/tui/src/tui/widgets/mod.rs | 65 ++++--------------------------- 2 files changed, 29 insertions(+), 60 deletions(-) diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 201890ad..d8068956 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -3774,8 +3774,9 @@ impl App { /// Return the (start, end) of the active selection, or `None`. /// `start` is inclusive, `end` is exclusive; both are char indices. pub fn selection_range(&self) -> Option<(usize, usize)> { - let anchor = self.selection_anchor?; - let cursor = self.cursor_position; + let total = char_count(&self.input); + let anchor = self.selection_anchor?.min(total); + let cursor = self.cursor_position.min(total); if anchor == cursor { return None; } @@ -4510,6 +4511,7 @@ impl App { self.history_index = Some(new_index); self.input = self.input_history[new_index].clone(); self.cursor_position = char_count(&self.input); + self.selection_anchor = None; self.selected_attachment_index = None; self.slash_menu_hidden = false; self.paste_burst.clear_after_explicit_paste(); @@ -4526,6 +4528,7 @@ impl App { self.history_index = Some(i + 1); self.input = self.input_history[i + 1].clone(); self.cursor_position = char_count(&self.input); + self.selection_anchor = None; self.selected_attachment_index = None; self.slash_menu_hidden = false; self.paste_burst.clear_after_explicit_paste(); @@ -4534,6 +4537,7 @@ impl App { if let Some(draft) = self.history_navigation_draft.take() { self.input = draft.input; self.cursor_position = draft.cursor.min(char_count(&self.input)); + self.selection_anchor = None; self.selected_attachment_index = None; self.slash_menu_hidden = false; self.paste_burst.clear_after_explicit_paste(); @@ -5914,6 +5918,22 @@ mod tests { assert!(app.history_index.is_none()); } + #[test] + fn input_history_navigation_clears_stale_selection() { + let mut app = App::new(test_options(false), &Config::default()); + app.input_history.push("previous input".to_string()); + app.input = "hello world".to_string(); + app.cursor_position = "hello ".chars().count(); + app.selection_anchor = Some(app.input.chars().count()); + + app.history_up(); + assert_eq!(app.input, "previous input"); + assert!(app.selection_anchor.is_none()); + + app.insert_char('x'); + assert_eq!(app.input, "previous inputx"); + } + #[test] fn input_history_restores_empty_draft_at_end_of_navigation() { let mut app = App::new(test_options(false), &Config::default()); diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 58cdffb7..2c478a29 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -667,12 +667,13 @@ impl Renderable for ComposerWidget<'_> { Style::default().fg(palette::TEXT_MUTED).italic(), ))); } else if let Some((sel_start, sel_end)) = self.app.selection_range() { - let line_ranges = visible_line_char_ranges( - &self.app.input, - &visible_lines, - content_width, - scroll_offset, - ); + let line_ranges: Vec<(usize, usize)> = + wrap_input_lines_for_mouse(&self.app.input, content_width) + .into_iter() + .skip(scroll_offset) + .take(visible_lines.len()) + .map(|(start, text)| (start, start + text.chars().count())) + .collect(); for (line_text, (line_start, line_end)) in visible_lines.iter().zip(line_ranges.iter()) { let spans = line_spans_with_selection( @@ -2443,58 +2444,6 @@ fn wrap_text(text: &str, width: usize) -> Vec { lines } -/// Compute the (char_start, char_end) range for each visible wrapped line. -/// `char_start` is inclusive, `char_end` is exclusive. -/// `scroll_offset` is the number of wrapped lines skipped from the top. -fn visible_line_char_ranges( - input: &str, - visible_lines: &[String], - width: usize, - scroll_offset: usize, -) -> Vec<(usize, usize)> { - if input.is_empty() || width == 0 { - return vec![(0, 0); visible_lines.len()]; - } - - let mut ranges = Vec::new(); - let mut char_idx = 0usize; - let mut line_start = 0usize; - let mut line_width = 0usize; - - for g in input.graphemes(true) { - if g == "\n" { - ranges.push((line_start, char_idx)); - char_idx += 1; - line_start = char_idx; - line_width = 0; - continue; - } - - let gw = g.width(); - if line_width + gw > width && line_width > 0 { - ranges.push((line_start, char_idx)); - line_start = char_idx; - line_width = 0; - } - char_idx += g.chars().count(); - line_width += gw; - if line_width >= width { - ranges.push((line_start, char_idx)); - line_start = char_idx; - line_width = 0; - } - } - ranges.push((line_start, char_idx)); - - // Use the actual scroll_offset to align with visible_lines. - let start = scroll_offset.min(ranges.len()); - ranges - .into_iter() - .skip(start) - .take(visible_lines.len()) - .collect() -} - fn line_spans_with_selection<'a>( line: &'a str, line_start: usize, From 11e1ec1fe323c999a3d58c004a0b706472165e5a Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 16:39:39 -0500 Subject: [PATCH 110/283] docs: add v0.8.47 CHANGELOG entries for composer, CNB mirror, fixes --- crates/tui/CHANGELOG.md | 50 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 6c266c89..924e4f2f 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Composer text selection with copy/cut.** Mouse drag and Shift+Arrow + selection in the composer input box, with Ctrl+C copy and Ctrl+X cut + support. Home, End, Ctrl+A, and Ctrl+E now clear the selection (#2228). +- **Copy transcript without visual-wrap newlines.** Transcript copy now + strips visual-wrap column line breaks from paragraphs, producing clean + text for pasting into editors or prompts (#1906). +- **Configurable base URL in /config view.** The `/config` panel now + displays the effective DeepSeek base URL (#1967). +- **CNB mirror support for China-friendly downloads.** Added + `CODEWHALE_RELEASE_BASE_URL` and `CODEWHALE_USE_CNB_MIRROR` to + both npm install scripts and Rust self-updater (#2222). +- **[✓] completion markers.** Checklist, plan, and tool completion + markers now render as `[✓]` instead of `[x]` (#1935). + +### Changed + +- **Project context loading now logs the source file.** (#2227) +- **macOS onboarding and empty-state layout pinned to top** instead + of vertically centered (#1837). +- **State-root migration continues.** Migrated 15+ storage paths to + prefer `~/.codewhale` with `~/.deepseek` fallback (#2231). +- **READMEs updated for the CodeWhale rename.** All three READMEs now + reference canonical `~/.codewhale` paths. + +### Fixed + +- **Deadlock when spawning multiple concurrent sub-agents.** Replaced + `RwLock`-based serialisation with a `Semaphore(1)` (#1856). +- **Steered/queued messages now render in correct transcript order.** + `steer_user_message` now flushes the active cell before inserting (#2225). +- **Session save test updated for managed sessions directory.** (#2223). +- **Loop guard reports Failed on halt.** Turn outcome correctly reports + `Failed` instead of `Completed` when the loop guard trips (#1859). +- **DEEPSEEK_YOLO env honoured on startup.** The `--yolo` flag is now + correctly merged with the `DEEPSEEK_YOLO` environment variable (#1870). + +### Community + +Thanks to contributors whose PRs landed in this release: +**@Fire-dtx** (#1856), +**@imkingjh999** (#2228), +**@harvey2011888** (#1859), +**@victorcheng2333** (#1870), +**@IIzzaya** (#1935), +**@PurplePulse** (#1837), +**@cyq1017** (#1967), +**@knqiufan** (#1906). + ## [0.8.46] - 2026-05-26 ### Added From ac6693581f8ae640d36414f826fb249b54c0acb4 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 16:40:16 -0500 Subject: [PATCH 111/283] fix(tools): restore checklist and planning tools to default active catalog PR #2076 deferred planning/checklist tools (checklist_write, update_plan, task_create, task_list, task_read) to reduce catalog tokens, but the system prompt actively instructs the model to use these tools. Without them in the active catalog, the model cannot call them until it first discovers them via tool_search, which it is not prompted to do for planning tools. Keep these tools in DEFAULT_ACTIVE_NATIVE_TOOLS so the model can follow the Constitution's Regulations (Tier 3) and the Mode: YOLO instructions. --- crates/tui/src/core/engine/tool_catalog.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/tui/src/core/engine/tool_catalog.rs b/crates/tui/src/core/engine/tool_catalog.rs index c699a0a8..65b194ce 100644 --- a/crates/tui/src/core/engine/tool_catalog.rs +++ b/crates/tui/src/core/engine/tool_catalog.rs @@ -32,6 +32,7 @@ pub(super) fn is_tool_search_tool(name: &str) -> bool { pub(super) const DEFAULT_ACTIVE_NATIVE_TOOLS: &[&str] = &[ "agent_open", "apply_patch", + "checklist_write", "edit_file", "exec_shell", "fetch_url", @@ -42,6 +43,10 @@ pub(super) const DEFAULT_ACTIVE_NATIVE_TOOLS: &[&str] = &[ "list_dir", "read_file", "run_tests", + "task_create", + "task_list", + "task_read", + "update_plan", "web_search", "write_file", ]; From b96fa37ea44a19ba30e482bc0b049d40bec0ab3a Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 26 May 2026 16:40:16 -0500 Subject: [PATCH 112/283] chore: add DeepSWE task verification script --- scripts/verify_task.sh | 48 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 scripts/verify_task.sh diff --git a/scripts/verify_task.sh b/scripts/verify_task.sh new file mode 100644 index 00000000..97689ebf --- /dev/null +++ b/scripts/verify_task.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# verify_task.sh +# Runs the DeepSWE verifier inside the task's Docker container. +# Expects model.patch at /tmp/deep-swe-verify//model.patch +TASK_ID="$1" +IMAGE="$2" +TASKS_DIR="/Volumes/VIXinSSD/whalebro/codewhale/deep-swe/tasks" +WORK_DIR="/tmp/deep-swe-verify/$TASK_ID" + +mkdir -p "$WORK_DIR" +RESULT_FILE="$WORK_DIR/result.txt" + +echo "[$TASK_ID] Pulling image..." +docker pull "$IMAGE" 2>&1 | tail -1 + +echo "[$TASK_ID] Running verifier..." +docker run --rm \ + --platform linux/amd64 \ + -v "$WORK_DIR/model.patch:/model.patch:ro" \ + -v "$TASKS_DIR/$TASK_ID/tests/test.patch:/tests/test.patch:ro" \ + -v "$TASKS_DIR/$TASK_ID/tests/test.sh:/verify.sh:ro" \ + "$IMAGE" \ + bash -c ' + set -e + mkdir -p /logs/verifier /logs/artifacts + cd /app + git apply --whitespace=nowarn /model.patch 2>/dev/null || { echo "PATCH_FAILED"; exit 2; } + bash /verify.sh > /logs/verifier/output.txt 2>&1 + EC=$? + if [ -f /logs/verifier/reward.txt ]; then + REWARD=$(cat /logs/verifier/reward.txt) + echo "REWARD=$REWARD" + else + # Extract from output + if grep -q "New tests exit code: 0" /logs/verifier/output.txt && \ + grep -q "Baseline exit code: 0" /logs/verifier/output.txt; then + echo "REWARD=1" + else + echo "REWARD=0" + fi + fi + echo "---OUTPUT_TAIL---" + tail -30 /logs/verifier/output.txt + ' > "$RESULT_FILE" 2>&1 + +echo "[$TASK_ID] Done. Result:" +cat "$RESULT_FILE" | grep -E 'REWARD|FAILED|PATCH_FAILED|passed' +echo "" From ed23a48e040302a571ebc9b07561e5558cf581a2 Mon Sep 17 00:00:00 2001 From: reidliu41 Date: Wed, 27 May 2026 07:33:27 +0800 Subject: [PATCH 113/283] feat: add new session command Add /new to start a fresh saved session from the TUI without overloading /clear. The command creates a distinct session id, resets conversation state, and keeps previous sessions available through /resume. Block unsafe switches when pending input or active work exists, with /new --force available for explicit discard. --- crates/tui/src/commands/core.rs | 40 ++++--- crates/tui/src/commands/mod.rs | 7 ++ crates/tui/src/commands/session.rs | 171 +++++++++++++++++++++++++++++ crates/tui/src/localization.rs | 7 ++ 4 files changed, 208 insertions(+), 17 deletions(-) diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index 0a50f1d8..44394485 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -46,6 +46,28 @@ pub fn help(app: &mut App, topic: Option<&str>) -> CommandResult { /// Clear conversation history pub fn clear(app: &mut App) -> CommandResult { + let todos_cleared = reset_conversation_state(app); + app.current_session_id = None; + let locale = app.ui_locale; + let message = if todos_cleared { + tr(locale, MessageId::ClearConversation).to_string() + } else { + tr(locale, MessageId::ClearConversationBusy).to_string() + }; + CommandResult::with_message_and_action( + message, + AppAction::SyncSession { + session_id: None, + messages: Vec::new(), + system_prompt: None, + model: app.model.clone(), + workspace: app.workspace.clone(), + }, + ) +} + +/// Reset the active conversation without choosing the next session id. +pub(crate) fn reset_conversation_state(app: &mut App) -> bool { app.clear_history(); app.mark_history_updated(); app.api_messages.clear(); @@ -78,23 +100,7 @@ pub fn clear(app: &mut App) -> CommandResult { app.session.last_reasoning_replay_tokens = None; app.session.turn_cache_history.clear(); app.session.last_cache_inspection = None; - app.current_session_id = None; - let locale = app.ui_locale; - let message = if todos_cleared { - tr(locale, MessageId::ClearConversation).to_string() - } else { - tr(locale, MessageId::ClearConversationBusy).to_string() - }; - CommandResult::with_message_and_action( - message, - AppAction::SyncSession { - session_id: None, - messages: Vec::new(), - system_prompt: None, - model: app.model.clone(), - workspace: app.workspace.clone(), - }, - ) + todos_cleared } /// Exit the application diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index f21df395..842625e3 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -298,6 +298,12 @@ pub const COMMANDS: &[CommandInfo] = &[ usage: "/fork", description_id: MessageId::CmdForkDescription, }, + CommandInfo { + name: "new", + aliases: &[], + usage: "/new [--force]", + description_id: MessageId::CmdNewDescription, + }, CommandInfo { name: "sessions", aliases: &["resume"], @@ -585,6 +591,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "rename" | "gaiming" | "chongmingming" => rename::rename(app, arg), "save" => session::save(app, arg), "fork" | "branch" => session::fork(app), + "new" => session::new_session(app, arg), "sessions" | "resume" => session::sessions(app, arg), "relay" | "batonpass" | "接力" => relay(app, arg), "load" | "jiazai" => session::load(app, arg), diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index a54c4403..a54426c1 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -135,6 +135,73 @@ pub fn fork(app: &mut App) -> CommandResult { ) } +/// Start a fresh saved session from the current TUI state. +pub fn new_session(app: &mut App, arg: Option<&str>) -> CommandResult { + let force = match arg.map(str::trim).filter(|s| !s.is_empty()) { + None => false, + Some("--force" | "force") => true, + Some(other) => { + return CommandResult::error(format!( + "Usage: /new [--force]\n\nUnknown argument: {other}" + )); + } + }; + + if !force { + let blockers = new_session_blockers(app); + if !blockers.is_empty() { + return CommandResult::error(format!( + "Cannot start a new session while {}. Run `/new --force` to discard pending work and start a fresh session.", + blockers.join(", ") + )); + } + } + + let new_id = uuid::Uuid::new_v4().to_string(); + super::core::reset_conversation_state(app); + app.clear_input(); + app.session_artifacts.clear(); + app.session_context_references.clear(); + app.tool_evidence.clear(); + app.current_session_id = Some(new_id.clone()); + app.session_title = Some("New Session".to_string()); + app.scroll_to_bottom(); + + CommandResult::with_message_and_action( + format!( + "Started new session {} (New Session). Previous sessions remain available via /resume.", + crate::session_manager::truncate_id(&new_id) + ), + AppAction::SyncSession { + session_id: Some(new_id), + messages: Vec::new(), + system_prompt: None, + model: app.model.clone(), + workspace: app.workspace.clone(), + }, + ) +} + +fn new_session_blockers(app: &App) -> Vec<&'static str> { + let mut blockers = Vec::new(); + if !app.input.trim().is_empty() { + blockers.push("the composer has unsent text"); + } + if !app.queued_messages.is_empty() || app.queued_draft.is_some() { + blockers.push("queued messages are pending"); + } + if app.is_loading || app.runtime_turn_status.as_deref() == Some("in_progress") { + blockers.push("a turn is in progress"); + } + if app.is_compacting { + blockers.push("context compaction is running"); + } + if app.task_panel.iter().any(|task| task.status == "running") { + blockers.push("background tasks are running"); + } + blockers +} + /// Load session from file pub fn load(app: &mut App, path: Option<&str>) -> CommandResult { let load_path = if let Some(p) = path { @@ -489,6 +556,110 @@ mod tests { } } + #[test] + fn new_session_from_resumed_state_creates_distinct_empty_session() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.current_session_id = Some("old-session".to_string()); + app.session_title = Some("Old Session".to_string()); + app.api_messages.push(crate::models::Message { + role: "user".to_string(), + content: vec![crate::models::ContentBlock::Text { + text: "continue this thread".to_string(), + cache_control: None, + }], + }); + app.add_message(HistoryCell::System { + content: "old transcript".to_string(), + }); + app.system_prompt = Some(crate::models::SystemPrompt::Text("old prompt".to_string())); + app.session.total_tokens = 123; + app.session.session_cost = 1.25; + + let result = new_session(&mut app, None); + + assert!(!result.is_error, "{:?}", result.message); + let new_id = app.current_session_id.clone().expect("new session id"); + assert_ne!(new_id, "old-session"); + assert_eq!(app.session_title.as_deref(), Some("New Session")); + assert!(app.api_messages.is_empty()); + assert!(app.history.is_empty()); + assert!(app.system_prompt.is_none()); + assert_eq!(app.session.total_tokens, 0); + assert_eq!(app.session.session_cost, 0.0); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("/resume") + ); + match result.action { + Some(AppAction::SyncSession { + session_id, + messages, + system_prompt, + .. + }) => { + assert_eq!(session_id.as_deref(), Some(new_id.as_str())); + assert!(messages.is_empty()); + assert!(system_prompt.is_none()); + } + other => panic!("expected SyncSession action, got {other:?}"), + } + } + + #[test] + fn new_session_blocks_unsent_input_without_force() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.current_session_id = Some("old-session".to_string()); + app.input = "draft text".to_string(); + + let result = new_session(&mut app, None); + + assert!(result.is_error); + assert_eq!(app.current_session_id.as_deref(), Some("old-session")); + assert_eq!(app.input, "draft text"); + assert!(result.action.is_none()); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("/new --force") + ); + } + + #[test] + fn new_session_force_discards_unsent_input() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.current_session_id = Some("old-session".to_string()); + app.input = "draft text".to_string(); + + let result = new_session(&mut app, Some("--force")); + + assert!(!result.is_error, "{:?}", result.message); + assert_ne!(app.current_session_id.as_deref(), Some("old-session")); + assert!(app.input.is_empty()); + assert!(matches!(result.action, Some(AppAction::SyncSession { .. }))); + } + + #[test] + fn new_session_blocks_in_flight_turn_without_force() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.current_session_id = Some("old-session".to_string()); + app.is_loading = true; + + let result = new_session(&mut app, None); + + assert!(result.is_error); + assert_eq!(app.current_session_id.as_deref(), Some("old-session")); + assert!(result.action.is_none()); + } + #[test] fn test_save_with_default_path_uses_managed_sessions_dir() { let tmpdir = TempDir::new().unwrap(); diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 874bb2ec..21809260 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -294,6 +294,7 @@ pub enum MessageId { CmdRlmDescription, CmdSaveDescription, CmdForkDescription, + CmdNewDescription, CmdSessionsDescription, CmdSettingsDescription, CmdSkillDescription, @@ -527,6 +528,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CmdReviewDescription, MessageId::CmdRlmDescription, MessageId::CmdSaveDescription, + MessageId::CmdNewDescription, MessageId::CmdSessionsDescription, MessageId::CmdSettingsDescription, MessageId::CmdSkillDescription, @@ -971,6 +973,7 @@ fn english(id: MessageId) -> &'static str { MessageId::CmdRlmDescription => "Open a persistent RLM context: /rlm [0-3] ", MessageId::CmdSaveDescription => "Save session to file", MessageId::CmdForkDescription => "Fork the active conversation into a sibling session", + MessageId::CmdNewDescription => "Start a fresh saved session", MessageId::CmdSessionsDescription => "Open session history picker", MessageId::CmdSettingsDescription => "Show persistent settings", MessageId::CmdSkillDescription => { @@ -1359,6 +1362,7 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CmdRlmDescription => "永続 RLM コンテキストを開く: /rlm [0-3] ", MessageId::CmdSaveDescription => "セッションをファイルに保存", MessageId::CmdForkDescription => "現在の会話を兄弟セッションに fork", + MessageId::CmdNewDescription => "新しい保存済みセッションを開始", MessageId::CmdSessionsDescription => "セッション履歴ピッカーを開く", MessageId::CmdSettingsDescription => "永続化された設定を表示", MessageId::CmdSkillDescription => { @@ -1702,6 +1706,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CmdRlmDescription => "打开持久 RLM 上下文:/rlm [0-3] ", MessageId::CmdSaveDescription => "将会话保存到文件", MessageId::CmdForkDescription => "将当前对话分叉为兄弟会话", + MessageId::CmdNewDescription => "开始一个新的已保存会话", MessageId::CmdSessionsDescription => "打开会话历史选择器", MessageId::CmdSettingsDescription => "显示持久化设置", MessageId::CmdSkillDescription => "激活技能,或安装/更新/卸载/信任社区技能", @@ -2037,6 +2042,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { } MessageId::CmdSaveDescription => "Salvar a sessão em arquivo", MessageId::CmdForkDescription => "Bifurcar a conversa ativa para uma sessão irmã", + MessageId::CmdNewDescription => "Iniciar uma nova sessão salva", MessageId::CmdSessionsDescription => "Abrir seletor de histórico de sessões", MessageId::CmdSettingsDescription => "Exibir as configurações persistidas", MessageId::CmdSkillDescription => { @@ -2428,6 +2434,7 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { } MessageId::CmdSaveDescription => "Guardar la sesión en archivo", MessageId::CmdForkDescription => "Bifurcar la conversación activa a una sesión hermana", + MessageId::CmdNewDescription => "Iniciar una nueva sesión guardada", MessageId::CmdSessionsDescription => "Abrir el selector de sesiones", MessageId::CmdSettingsDescription => "Mostrar las configuraciones persistidas", MessageId::CmdSkillDescription => { From 5ed741dc8c8b449f4ecd90479bae613267783837 Mon Sep 17 00:00:00 2001 From: reidliu41 Date: Wed, 27 May 2026 10:24:23 +0800 Subject: [PATCH 114/283] fix(provider): keep picker selection visible Make the /provider modal size itself to the provider list when space allows, and scroll the rendered list when the selected provider moves past the visible rows. Also make the selected row use a continuous, subtle highlight so the current selection remains visible without looking disconnected. Adds regression coverage for bottom providers, initial bottom selection, full height rendering, and selected-row highlighting. --- crates/tui/src/tui/provider_picker.rs | 144 +++++++++++++++++++++++--- 1 file changed, 128 insertions(+), 16 deletions(-) diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index b2ac79e6..03cf07b9 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -21,7 +21,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ buffer::Buffer, layout::{Constraint, Direction, Layout, Rect}, - style::{Modifier, Style}, + style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph, Widget}, }; @@ -116,6 +116,28 @@ impl ProviderPickerView { } } + fn visible_start(&self, visible_rows: usize) -> usize { + if visible_rows == 0 { + return 0; + } + let max_start = self.providers.len().saturating_sub(visible_rows); + self.selected_idx + .saturating_add(1) + .saturating_sub(visible_rows) + .min(max_start) + } + + fn selected_row_style(fg: Color) -> Style { + Style::default() + .fg(fg) + .bg(palette::SURFACE_ELEVATED) + .add_modifier(Modifier::BOLD) + } + + fn selected_row_bg_style() -> Style { + Style::default().bg(palette::SURFACE_ELEVATED) + } + fn render_list(&self, area: Rect, buf: &mut Buffer) { let outer = Block::default() .title(Line::from(Span::styled( @@ -138,39 +160,64 @@ impl ProviderPickerView { let inner = outer.inner(area); outer.render(area, buf); - let mut lines: Vec = Vec::with_capacity(self.providers.len()); - for (idx, (provider, has_key)) in self.providers.iter().enumerate() { + let visible_rows = usize::from(inner.height); + let visible_start = self.visible_start(visible_rows); + let mut lines: Vec = Vec::with_capacity(visible_rows); + for (idx, (provider, has_key)) in self + .providers + .iter() + .enumerate() + .skip(visible_start) + .take(visible_rows) + { let is_selected = idx == self.selected_idx; let is_active = *provider == self.active_provider; let arrow = if is_selected { "▸" } else { " " }; let active_dot = if is_active { " *" } else { " " }; - let label_style = if is_selected { + let spacer_style = if is_selected { + Self::selected_row_bg_style() + } else { Style::default() - .fg(palette::SELECTION_TEXT) - .bg(palette::SELECTION_BG) - .add_modifier(Modifier::BOLD) + }; + let label_style = if is_selected { + Self::selected_row_style(palette::TEXT_PRIMARY) } else { Style::default().fg(palette::TEXT_PRIMARY) }; let hint_style = if is_selected { - Style::default() - .fg(palette::SELECTION_TEXT) - .bg(palette::SELECTION_BG) + let hint_fg = if *has_key { + palette::TEXT_MUTED + } else { + palette::STATUS_WARNING + }; + Self::selected_row_style(hint_fg) } else if *has_key { Style::default().fg(palette::TEXT_MUTED) } else { Style::default().fg(palette::STATUS_WARNING) }; let hint = Self::provider_hint(*provider, *has_key); - lines.push(Line::from(vec![ - Span::raw(" "), + let mut line = Line::from(vec![ + Span::styled(" ", spacer_style), Span::styled(arrow, label_style), - Span::raw(" "), + Span::styled(" ", spacer_style), Span::styled(provider.display_name().to_string(), label_style), Span::styled(active_dot, label_style), - Span::raw(" "), + Span::styled(" ", spacer_style), Span::styled(hint, hint_style), - ])); + ]); + if is_selected { + line.style = Self::selected_row_bg_style(); + let target_width = usize::from(inner.width); + let line_width = line.width(); + if line_width < target_width { + line.spans.push(Span::styled( + " ".repeat(target_width - line_width), + Self::selected_row_bg_style(), + )); + } + } + lines.push(line); } Paragraph::new(lines).render(inner, buf); } @@ -347,7 +394,7 @@ impl ModalView for ProviderPickerView { fn render(&self, area: Rect, buf: &mut Buffer) { let popup_width = 64.min(area.width.saturating_sub(4)).max(40); let popup_height = match self.stage { - Stage::List => 12, + Stage::List => (self.providers.len() as u16).saturating_add(2), Stage::KeyEntry => 10, } .min(area.height.saturating_sub(4)) @@ -388,6 +435,16 @@ mod tests { panic!("provider {provider:?} not found in picker"); } + fn render_text(picker: &ProviderPickerView, width: u16, height: u16) -> String { + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + picker.render(area, &mut buf); + (0..height) + .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::()) + .collect::>() + .join("\n") + } + #[test] fn picker_lists_all_providers() { let config = Config::default(); @@ -529,4 +586,59 @@ mod tests { } assert_eq!(picker.api_key_input, "abcdef"); } + + #[test] + fn small_list_render_keeps_selected_provider_visible_after_down_navigation() { + let config = Config::default(); + let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); + move_to_provider(&mut picker, ApiProvider::Ollama); + + let rendered = render_text(&picker, 80, 12); + + assert!(rendered.contains("Ollama")); + assert!(!rendered.contains("DeepSeek *")); + } + + #[test] + fn small_list_render_keeps_initial_active_provider_visible() { + let config = Config::default(); + let picker = ProviderPickerView::new(ApiProvider::Ollama, &config); + + let rendered = render_text(&picker, 80, 12); + + assert!(rendered.contains("Ollama *")); + } + + #[test] + fn tall_list_render_shows_all_providers_without_scrolling() { + let config = Config::default(); + let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); + + let rendered = render_text(&picker, 80, 20); + + assert!(rendered.contains("DeepSeek *")); + assert!(rendered.contains("Ollama")); + } + + #[test] + fn selected_provider_row_uses_strong_highlight() { + let config = Config::default(); + let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); + let area = Rect::new(0, 0, 80, 20); + let mut buf = Buffer::empty(area); + + picker.render(area, &mut buf); + + let highlighted_cells = area + .positions() + .filter(|position| { + let cell = &buf[*position]; + cell.bg == palette::SURFACE_ELEVATED + }) + .count(); + assert!( + highlighted_cells >= 32, + "selected provider row should use a visible continuous highlight" + ); + } } From 198acc72bae04620a4b3ddaab86348970b84cdaf Mon Sep 17 00:00:00 2001 From: reidliu41 Date: Wed, 27 May 2026 10:34:38 +0800 Subject: [PATCH 115/283] Make the selected row easier to see with a continuous subtle highlight, and wrap Up/Down navigation between the first and last providers. --- crates/tui/src/tui/provider_picker.rs | 29 +++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index 03cf07b9..a4cdfb6c 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -65,13 +65,23 @@ impl ProviderPickerView { } fn move_up(&mut self) { - if self.selected_idx > 0 { + if self.providers.is_empty() { + return; + } + if self.selected_idx == 0 { + self.selected_idx = self.providers.len() - 1; + } else { self.selected_idx -= 1; } } fn move_down(&mut self) { - if self.selected_idx + 1 < self.providers.len() { + if self.providers.is_empty() { + return; + } + if self.selected_idx + 1 == self.providers.len() { + self.selected_idx = 0; + } else { self.selected_idx += 1; } } @@ -497,6 +507,18 @@ mod tests { assert_eq!(picker.active_provider, ApiProvider::Openrouter); } + #[test] + fn list_navigation_wraps_between_first_and_last_provider() { + let config = Config::default(); + let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); + + picker.handle_key(key(KeyCode::Up)); + assert_eq!(picker.selected_provider(), ApiProvider::Ollama); + + picker.handle_key(key(KeyCode::Down)); + assert_eq!(picker.selected_provider(), ApiProvider::Deepseek); + } + #[test] fn enter_with_no_key_transitions_to_key_entry_stage() { let config = Config::default(); @@ -516,8 +538,7 @@ mod tests { ..Config::default() }; let mut picker = ProviderPickerView::new(ApiProvider::NvidiaNim, &config); - // Move up twice to DeepSeek (index 0), which has a key from the config. - picker.handle_key(key(KeyCode::Up)); + // Move up once to DeepSeek (index 0), which has a key from the config. picker.handle_key(key(KeyCode::Up)); let action = picker.handle_key(key(KeyCode::Enter)); match action { From 6dd8394dfe8615d4858b01a0f3054cff3345c1fc Mon Sep 17 00:00:00 2001 From: AccMoment <1161677781@qq.com> Date: Wed, 27 May 2026 20:58:48 +0800 Subject: [PATCH 116/283] feat(update):Add proxy option to update command Update docs to introduce update command proxy options --- README.ja-JP.md | 3 ++ README.md | 3 ++ README.zh-CN.md | 2 + crates/cli/src/lib.rs | 39 ++++++++++++-- crates/cli/src/update.rs | 107 ++++++++++++++++++++++++++++++--------- 5 files changed, 128 insertions(+), 26 deletions(-) diff --git a/README.ja-JP.md b/README.ja-JP.md index 667aafb5..6b5d8abc 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -51,6 +51,9 @@ cargo install codewhale-cli --locked --force cargo install codewhale-tui --locked --force ``` +> codewhale update は --proxy をサポートしており、プロキシ経由で更新できます +> 例: codewhale update --proxy https://localhost:7897 + [![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/codewhale)](https://www.npmjs.com/package/codewhale) [![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli) diff --git a/README.md b/README.md index 3213ee15..8a5baafc 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,9 @@ cargo install codewhale-cli --locked --force cargo install codewhale-tui --locked --force ``` +> codewhale update now supports --proxy, update through a proxy +> eg: codewhale update --proxy https://localhost:7897 + [![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/codewhale)](https://www.npmjs.com/package/codewhale) [![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli) diff --git a/README.zh-CN.md b/README.zh-CN.md index f079cc80..c4ded4f3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -59,6 +59,8 @@ brew update && brew upgrade deepseek-tui cargo install codewhale-cli --locked --force cargo install codewhale-tui --locked --force ``` +> codewhale update 现在可添加 --proxy ,通过代理下载更新 +> eg: codewhale update --proxy https://localhost:7897 [![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/codewhale)](https://www.npmjs.com/package/codewhale) diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 9f98eebf..6d5cf17c 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -240,6 +240,8 @@ struct UpdateArgs { /// Update to the latest beta release instead of the latest stable release. #[arg(long)] beta: bool, + #[arg(long)] + proxy: Option, } #[derive(Debug, Args)] @@ -569,7 +571,7 @@ fn run() -> Result<()> { Ok(()) } Some(Commands::Metrics(args)) => run_metrics_command(args), - Some(Commands::Update(args)) => update::run_update(args.beta), + Some(Commands::Update(args)) => update::run_update(args), None => { let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides); let forwarded = root_tui_passthrough(&cli)?; @@ -1817,13 +1819,19 @@ mod tests { let cli = parse_ok(&["codewhale", "update"]); assert!(matches!( cli.command, - Some(Commands::Update(UpdateArgs { beta: false })) + Some(Commands::Update(UpdateArgs { + beta: false, + proxy: None + })) )); let cli = parse_ok(&["codewhale", "update", "--beta"]); assert!(matches!( cli.command, - Some(Commands::Update(UpdateArgs { beta: true })) + Some(Commands::Update(UpdateArgs { + beta: true, + proxy: None + })) )); } @@ -2427,6 +2435,31 @@ mod tests { let _ = std::fs::remove_file(path); } + #[test] + fn udpate_parse_with_proxy() { + let cli = parse_ok(&["deepseek", "update", "--proxy", "http:localhost:7897"]); + + let args = match cli.command { + Some(Commands::Update(args)) => args, + other => panic!("expected Update with proxy, got {other:?}"), + }; + assert_eq!( + args.proxy.expect("should have proxy"), + "http:localhost:7897" + ); + } + + #[test] + fn udpate_parse_without_proxy() { + let cli = parse_ok(&["deepseek", "update"]); + + let args = match cli.command { + Some(Commands::Update(args)) => args, + other => panic!("expected Update, got {other:?}"), + }; + assert!(args.proxy.is_none()); + } + #[test] fn dispatch_keyring_recovery_self_heals_into_config_file() { use codewhale_secrets::{InMemoryKeyringStore, KeyringStore}; diff --git a/crates/cli/src/update.rs b/crates/cli/src/update.rs index 2ab35ef1..44b26537 100644 --- a/crates/cli/src/update.rs +++ b/crates/cli/src/update.rs @@ -4,11 +4,13 @@ //! `github.com/Hmbown/CodeWhale/releases/latest`, downloads the //! platform-correct binary, verifies its SHA256 checksum, and atomically //! replaces the currently running binary. - +use crate::UpdateArgs; use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::time::Duration; use anyhow::{Context, Result, bail}; +use reqwest::Proxy; use std::io::Write; const CHECKSUM_MANIFEST_ASSET: &str = "codewhale-artifacts-sha256.txt"; @@ -26,19 +28,27 @@ const LEGACY_UPDATE_VERSION_ENV: &str = "DEEPSEEK_VERSION"; const UPDATE_USER_AGENT: &str = "codewhale-updater"; /// Run the self-update workflow. -pub fn run_update(beta: bool) -> Result<()> { +pub fn run_update(args: UpdateArgs) -> Result<()> { + let beta = args.beta; let current_exe = std::env::current_exe().context("failed to determine current executable path")?; let targets = update_targets_for_exe(¤t_exe); let channel = ReleaseChannel::from_beta_flag(beta); let current_version = env!("CARGO_PKG_VERSION"); + let proxy = if let Some(proxy_str) = &args.proxy { + validate_and_build_proxy(proxy_str)? + } else { + None + }; + println!("Checking for {} updates...", channel.label()); println!("Current binary: {}", current_exe.display()); println!("Current version: v{current_version}"); // Step 1: Fetch latest release metadata - let fetched = fetch_latest_release(channel).with_context(update_network_fallback_hint)?; + let fetched = + fetch_latest_release(channel, &proxy).with_context(update_network_fallback_hint)?; let release = &fetched.release; let latest_tag = &release.tag_name; println!("Latest {} release: {latest_tag}", channel.label()); @@ -59,8 +69,8 @@ pub fn run_update(beta: bool) -> Result<()> { let checksum_manifest = match select_checksum_manifest_asset(release) { Some(checksum_asset) => { println!("Downloading {}...", checksum_asset.name); - let checksum_bytes = - download_url(&checksum_asset.browser_download_url).with_context(|| { + let checksum_bytes = download_url(&checksum_asset.browser_download_url, &proxy) + .with_context(|| { format!( "failed to download {}\n{}", checksum_asset.name, @@ -95,7 +105,7 @@ pub fn run_update(beta: bool) -> Result<()> { })?; println!("Downloading {}...", asset.name); - let bytes = download_url(&asset.browser_download_url).with_context(|| { + let bytes = download_url(&asset.browser_download_url, &proxy).with_context(|| { format!( "failed to download {}\n{}", asset.name, @@ -174,6 +184,49 @@ enum ReleaseSource { Mirror { base_url: String }, } +// Validate the proxy URL and optionally test connectivity before proceeding. +fn validate_and_build_proxy(proxy_str: &str) -> Result> { + let valid_url = reqwest::Url::parse(proxy_str).with_context(|| { + format!( + "invalid proxy URL: {proxy_str}\n\ + Expected format: http://host:port, https://host:port, or socks5://host:port" + ) + })?; + + let proxy = reqwest::Proxy::all(valid_url)?; + + // Quick connectivity test through the proxy + let client = reqwest::blocking::Client::builder() + .proxy(proxy.clone()) + .user_agent(UPDATE_USER_AGENT) + .timeout(Duration::from_secs(10)) + .build() + .context("Could not build proxy HTTP client")?; + + match client.head(LATEST_RELEASE_URL).send() { + Ok(_) => Ok(Some(proxy)), + Err(e) => { + // Give a clear actionable error rather than a raw reqwest error + let hint = if e.is_timeout() || e.is_connect() { + "could not connect to the proxy server" + } else if e.is_request() { + "the request was sent but no response was received" + } else { + "an unexpected network error occurred" + }; + bail!( + "proxy connectivity failed: {hint}\n\ + Proxy URL: {proxy_str}\n\ + Details: {e}\n\ + Please verify:\n\ + - The proxy URL is correct\n\ + - The proxy server is running and reachable\n\ + - The proxy allows outbound connections to api.github.com" + ) + } + } +} + pub(crate) fn release_arch_for_rust_arch(arch: &str) -> &str { match arch { "aarch64" => "arm64", @@ -342,15 +395,21 @@ struct Asset { browser_download_url: String, } -fn update_http_client() -> Result { - reqwest::blocking::Client::builder() +fn update_http_client(proxy: &Option) -> Result { + let mut builder = reqwest::blocking::Client::builder(); + + if let Some(p) = proxy { + builder = builder.proxy(p.clone()); + } + + builder .user_agent(UPDATE_USER_AGENT) .build() .context("failed to build update HTTP client") } /// Fetch the latest release metadata from GitHub. -fn fetch_latest_release(channel: ReleaseChannel) -> Result { +fn fetch_latest_release(channel: ReleaseChannel, proxy: &Option) -> Result { let version = update_version_from_env().unwrap_or_else(|| env!("CARGO_PKG_VERSION").into()); if let Some(base_url) = release_base_url_from_env(&version) { return Ok(FetchedRelease { @@ -364,8 +423,8 @@ fn fetch_latest_release(channel: ReleaseChannel) -> Result { }); } let release = match channel { - ReleaseChannel::Stable => fetch_latest_release_from_url(LATEST_RELEASE_URL), - ReleaseChannel::Beta => fetch_latest_beta_release_from_url(RELEASES_URL), + ReleaseChannel::Stable => fetch_latest_release_from_url(LATEST_RELEASE_URL, proxy), + ReleaseChannel::Beta => fetch_latest_beta_release_from_url(RELEASES_URL, proxy), }?; Ok(FetchedRelease { release, @@ -454,8 +513,8 @@ fn update_network_fallback_hint() -> String { ) } -fn fetch_latest_release_from_url(url: &str) -> Result { - let client = update_http_client()?; +fn fetch_latest_release_from_url(url: &str, proxy: &Option) -> Result { + let client = update_http_client(proxy)?; let response = client .get(url) .header(reqwest::header::ACCEPT, "application/vnd.github+json") @@ -477,8 +536,8 @@ fn fetch_latest_release_from_url(url: &str) -> Result { Ok(release) } -fn fetch_latest_beta_release_from_url(url: &str) -> Result { - let client = update_http_client()?; +fn fetch_latest_beta_release_from_url(url: &str, proxy: &Option) -> Result { + let client = update_http_client(proxy)?; let response = client .get(url) .header(reqwest::header::ACCEPT, "application/vnd.github+json") @@ -553,8 +612,8 @@ fn version_is_beta(version: &semver::Version) -> bool { } /// Download a URL to bytes. -fn download_url(url: &str) -> Result> { - let client = update_http_client()?; +fn download_url(url: &str, proxy: &Option) -> Result> { + let client = update_http_client(proxy)?; let response = client .get(url) .send() @@ -1119,7 +1178,8 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win ] }"#; let (url, request_rx, handle) = serve_http_once("200 OK", "application/json", body); - let release = fetch_latest_release_from_url(&url).expect("release JSON should parse"); + let release = + fetch_latest_release_from_url(&url, &None).expect("release JSON should parse"); assert_eq!(release.tag_name, "v9.9.9"); assert_eq!(release.assets.len(), 2); @@ -1142,7 +1202,7 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win fn fetch_latest_release_from_url_reports_http_errors() { let (url, _request_rx, handle) = serve_http_once("500 Internal Server Error", "text/plain", b"server broke"); - let err = fetch_latest_release_from_url(&url).expect_err("HTTP 500 should fail"); + let err = fetch_latest_release_from_url(&url, &None).expect_err("HTTP 500 should fail"); assert!( err.to_string().contains("HTTP 500"), @@ -1162,8 +1222,8 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win { "tag_name": "v0.9.0-beta.1", "prerelease": true, "assets": [] } ]"#; let (url, request_rx, handle) = serve_http_once("200 OK", "application/json", body); - let release = - fetch_latest_beta_release_from_url(&url).expect("beta release JSON should parse"); + let release = fetch_latest_beta_release_from_url(&url, &None) + .expect("beta release JSON should parse"); assert_eq!(release.tag_name, "v0.9.0-beta.2"); assert!(release.prerelease); @@ -1184,7 +1244,8 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win { "tag_name": "v0.9.0", "prerelease": false, "assets": [] } ]"#; let (url, _request_rx, handle) = serve_http_once("200 OK", "application/json", body); - let err = fetch_latest_beta_release_from_url(&url).expect_err("missing beta should fail"); + let err = + fetch_latest_beta_release_from_url(&url, &None).expect_err("missing beta should fail"); assert!( err.to_string().contains("no beta release found"), @@ -1197,7 +1258,7 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win fn download_url_reads_binary_body_with_updater_user_agent() { let (url, request_rx, handle) = serve_http_once("200 OK", "application/octet-stream", b"\0binary bytes"); - let bytes = download_url(&url).expect("binary download should succeed"); + let bytes = download_url(&url, &None).expect("binary download should succeed"); assert_eq!(bytes, b"\0binary bytes"); From 8ed924f3d52d2cb31065215bb8d63332b42e8a25 Mon Sep 17 00:00:00 2001 From: Hu Qiantao Date: Wed, 27 May 2026 21:26:41 +0800 Subject: [PATCH 117/283] fix(engine): recover from stalled in-progress turns reconcile_turn_liveness() had a blind spot: when TurnStarted arrived (setting runtime_turn_status to "in_progress") but TurnComplete never came (sub-agent hang, engine panic, lost event), neither existing watchdog branch fired. is_loading stayed true permanently, queuing all subsequent messages. Add Branch 3 with a 5-minute timeout (matched to stream idle timeout) that checks turn_started_at for staleness when the turn is stuck in "in_progress" with no running sub-agents. --- crates/tui/src/tui/ui.rs | 27 +++++++++++++++++++++++++++ crates/tui/src/tui/ui/tests.rs | 25 ++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index fb89de61..e7fecc72 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -146,6 +146,11 @@ const UI_IDLE_POLL_MS: u64 = 48; const UI_ACTIVE_POLL_MS: u64 = 24; const WEB_CONFIG_POLL_MS: u64 = 16; const DISPATCH_WATCHDOG_TIMEOUT: Duration = Duration::from_secs(30); +/// Maximum wall-clock time a turn may stay in `"in_progress"` before the UI +/// assumes the engine stalled (e.g. sub-agent hang, lost completion event, +/// engine panic). Matched to [`DEFAULT_STREAM_IDLE_TIMEOUT`] so legitimate +/// long-running tool chains are not interrupted prematurely. +const TURN_STALL_WATCHDOG_TIMEOUT: Duration = Duration::from_secs(300); // Forced repaint cadence while a turn is live (model loading, compacting, // sub-agents running). Drives the footer water-spout animation as well as // the per-tool spinner pulse — keep this fast enough that the spout reads as @@ -3901,6 +3906,28 @@ fn reconcile_turn_liveness(app: &mut App, now: Instant, has_running_agents: bool return true; } + // Branch 3: turn started but never completed — engine may have + // panicked, sub-agent may be stuck, or the completion event was lost. + if app.is_loading + && matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) + && !has_running_agents + && !app.is_compacting + && app.turn_started_at.is_some_and(|started| { + now.saturating_duration_since(started) > TURN_STALL_WATCHDOG_TIMEOUT + }) + { + app.is_loading = false; + app.turn_started_at = None; + app.runtime_turn_status = None; + app.dispatch_started_at = None; + app.push_status_toast( + "Turn stalled — no completion signal received. Please try again.", + StatusToastLevel::Error, + None, + ); + return true; + } + false } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 4f0baa5b..97f09750 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -2021,17 +2021,36 @@ fn turn_liveness_leaves_active_turn_running() { let mut app = create_test_app(); app.is_loading = true; app.runtime_turn_status = Some("in_progress".to_string()); - app.dispatch_started_at = - Some(Instant::now() - DISPATCH_WATCHDOG_TIMEOUT - Duration::from_secs(10)); + app.turn_started_at = Some(Instant::now() - Duration::from_secs(60)); let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false); assert!(!recovered); assert!(app.is_loading); - assert!(app.dispatch_started_at.is_some()); + assert!(app.turn_started_at.is_some()); assert!(app.status_toasts.is_empty()); } +#[test] +fn turn_liveness_recovers_stalled_in_progress_turn() { + let mut app = create_test_app(); + app.is_loading = true; + app.runtime_turn_status = Some("in_progress".to_string()); + app.turn_started_at = + Some(Instant::now() - TURN_STALL_WATCHDOG_TIMEOUT - Duration::from_millis(1)); + + let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false); + + assert!(recovered); + assert!(!app.is_loading); + assert!(app.turn_started_at.is_none()); + assert!(app.runtime_turn_status.is_none()); + assert!(app.dispatch_started_at.is_none()); + let toast = app.status_toasts.back().expect("stall toast"); + assert_eq!(toast.level, StatusToastLevel::Error); + assert!(toast.text.contains("Turn stalled")); +} + #[test] fn fixed_model_auto_thinking_skips_auto_model_router() { let mut app = create_test_app(); From df63a18a26477f5dfe0db2d436c51d8142fd2a5e Mon Sep 17 00:00:00 2001 From: LING71671 <1739677116@qq.com> Date: Thu, 21 May 2026 19:23:35 +0800 Subject: [PATCH 118/283] fix(tui): show effective cost currency in config view --- crates/tui/src/tui/views/mod.rs | 92 ++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 68ce1ac7..0b6a805f 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -575,6 +575,7 @@ pub struct ConfigView { filter: String, status: Option, locale: Locale, + effective_cost_currency: String, last_visible_rows: Cell, last_row_hitboxes: RefCell>, } @@ -819,6 +820,7 @@ impl ConfigView { filter: String::new(), status: None, locale: app.ui_locale, + effective_cost_currency: cost_currency_config_value(app), last_visible_rows: Cell::new(0), last_row_hitboxes: RefCell::new(Vec::new()), } @@ -841,7 +843,7 @@ impl ConfigView { let section = row.section.label().to_lowercase(); let key = row.key.to_lowercase(); - let value = row.value.to_lowercase(); + let value = self.row_display_value(row).to_lowercase(); let scope = row.scope.label().to_lowercase(); filter.split_whitespace().all(|term| { @@ -1120,6 +1122,25 @@ impl ConfigView { self.update_filter(|filter| filter.clear()); } + + fn row_display_value(&self, row: &ConfigRow) -> String { + if row.key == "cost_currency" + && row.scope == ConfigScope::Saved + && row.value != self.effective_cost_currency + { + format!("{} (effective {})", row.value, self.effective_cost_currency) + } else { + row.value.clone() + } + } +} + +fn cost_currency_config_value(app: &App) -> String { + match app.cost_currency { + crate::pricing::CostCurrency::Usd => "usd", + crate::pricing::CostCurrency::Cny => "cny", + } + .to_string() } fn config_hint_for_key(key: &str) -> &'static str { @@ -1137,6 +1158,7 @@ fn config_hint_for_key(key: &str) -> &'static str { "theme" => "system | dark | light | grayscale", "locale" => "auto | en | ja | zh-Hans | pt-BR", "background_color" => "#RRGGBB | default", + "cost_currency" => "usd | cny", "default_mode" => "agent | plan | yolo", "sidebar_width" => "10..=50", "sidebar_focus" => "auto | work | tasks | agents | context | hidden", @@ -1444,7 +1466,10 @@ impl ModalView for ConfigView { } else { Style::default().fg(palette::TEXT_PRIMARY) }; - let value = truncate_view_text(&row.value, CONFIG_VALUE_COLUMN_WIDTH); + let value = truncate_view_text( + &self.row_display_value(row), + CONFIG_VALUE_COLUMN_WIDTH, + ); let mut line = Line::from(format!( " {:, + _lock: MutexGuard<'static, ()>, + } + + impl ConfigSettingsEnvGuard { + fn new(settings_toml: &str) -> Self { + let lock = crate::test_support::lock_test_env(); + let tmp = TempDir::new().expect("settings tempdir"); + let config_path = tmp.path().join(".deepseek").join("config.toml"); + let settings_path = config_path + .parent() + .expect("settings parent") + .join("settings.toml"); + std::fs::create_dir_all(config_path.parent().expect("config parent")) + .expect("config dir"); + std::fs::write(&settings_path, settings_toml).expect("settings file"); + let previous_config_path = std::env::var_os("DEEPSEEK_CONFIG_PATH"); + unsafe { + std::env::set_var("DEEPSEEK_CONFIG_PATH", &config_path); + } + Self { + _tmp: tmp, + previous_config_path, + _lock: lock, + } + } + } + + impl Drop for ConfigSettingsEnvGuard { + fn drop(&mut self) { + unsafe { + match self.previous_config_path.take() { + Some(previous) => std::env::set_var("DEEPSEEK_CONFIG_PATH", previous), + None => std::env::remove_var("DEEPSEEK_CONFIG_PATH"), + } + } + } + } fn create_test_app() -> App { let options = TuiOptions { @@ -2231,6 +2300,25 @@ mod tests { assert_eq!(row.value, "https://ui-config-view.local/v1"); } + #[test] + fn config_view_cost_currency_shows_saved_and_effective_runtime_currency() { + let _guard = ConfigSettingsEnvGuard::new("locale = \"zh-Hans\"\ncost_currency = \"usd\"\n"); + let app = create_test_app(); + assert_eq!(app.ui_locale, Locale::ZhHans); + assert_eq!(app.cost_currency, crate::pricing::CostCurrency::Cny); + + let view = ConfigView::new_for_app(&app); + let row = view + .rows + .iter() + .find(|row| row.key == "cost_currency") + .expect("cost_currency row"); + + assert_eq!(row.value, "usd"); + assert_eq!(view.row_display_value(row), "usd (effective cny)"); + assert_eq!(Settings::load().expect("settings").cost_currency, "usd"); + } + #[test] fn config_view_exposes_all_available_saved_settings() { let app = create_test_app(); From 6399d560be3ed81a585f402d61a44bfe541681a1 Mon Sep 17 00:00:00 2001 From: Hu Qiantao Date: Wed, 27 May 2026 21:39:03 +0800 Subject: [PATCH 119/283] fix(tui): finalize transcript cells on stall recovery The watchdog Branch 3 recovery left in-flight tool cells and streaming assistant messages in a running state, causing permanent spinners in the transcript. Also left runtime_turn_id stale, showing "(in progress)" for a turn that had already been recovered. Align the cleanup with apply_engine_error_to_app: finalize thinking, streaming assistant, and active cells as interrupted; reset streaming state; clear runtime_turn_id and streaming indices. --- crates/tui/src/tui/ui.rs | 10 ++++++++++ crates/tui/src/tui/ui/tests.rs | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index e7fecc72..e73ec537 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3916,9 +3916,19 @@ fn reconcile_turn_liveness(app: &mut App, now: Instant, has_running_agents: bool now.saturating_duration_since(started) > TURN_STALL_WATCHDOG_TIMEOUT }) { + // Finalize in-flight thinking / assistant / tool cells so the + // transcript doesn't show permanent spinners after recovery. + streaming_thinking::finalize_current(app); + app.finalize_streaming_assistant_as_interrupted(); + app.finalize_active_cell_as_interrupted(); + app.streaming_state.reset(); + app.streaming_message_index = None; + app.streaming_thinking_active_entry = None; + app.is_loading = false; app.turn_started_at = None; app.runtime_turn_status = None; + app.runtime_turn_id = None; app.dispatch_started_at = None; app.push_status_toast( "Turn stalled — no completion signal received. Please try again.", diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 97f09750..d41cf68b 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -2036,8 +2036,10 @@ fn turn_liveness_recovers_stalled_in_progress_turn() { let mut app = create_test_app(); app.is_loading = true; app.runtime_turn_status = Some("in_progress".to_string()); + app.runtime_turn_id = Some("stale-turn-id".to_string()); app.turn_started_at = Some(Instant::now() - TURN_STALL_WATCHDOG_TIMEOUT - Duration::from_millis(1)); + app.streaming_message_index = Some(0); let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false); @@ -2045,7 +2047,10 @@ fn turn_liveness_recovers_stalled_in_progress_turn() { assert!(!app.is_loading); assert!(app.turn_started_at.is_none()); assert!(app.runtime_turn_status.is_none()); + assert!(app.runtime_turn_id.is_none()); assert!(app.dispatch_started_at.is_none()); + assert!(app.streaming_message_index.is_none()); + assert!(app.streaming_thinking_active_entry.is_none()); let toast = app.status_toasts.back().expect("stall toast"); assert_eq!(toast.level, StatusToastLevel::Error); assert!(toast.text.contains("Turn stalled")); From 95ba01eba8e2cb8b16fe65263ceafef8f09cf4ed Mon Sep 17 00:00:00 2001 From: Ben Gao Date: Wed, 27 May 2026 21:43:45 +0800 Subject: [PATCH 120/283] fix(skills): align skills API with TUI command behavior - Use discover_for_workspace_and_dir() instead of SkillRegistry::discover() to search all skill directories (workspace + global), matching TUI /skills - Add is_bundled field to SkillEntry for built-in skill identification - Add directories field to SkillsResponse showing all search paths - Use skill.path instead of constructing path from skills_dir + name - Update set_skill_enabled to use the same discovery logic --- crates/tui/src/runtime_api.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 20110cc4..90c1156e 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -40,7 +40,6 @@ use crate::runtime_threads::{ }; use crate::session_manager::{SavedSession, SessionManager, SessionMetadata, default_sessions_dir}; use crate::skill_state::SkillStateStore; -use crate::skills::SkillRegistry; use crate::task_manager::{ NewTaskRequest, SharedTaskManager, TaskManager, TaskManagerConfig, TaskRecord, TaskSummary, }; @@ -261,11 +260,13 @@ struct SkillEntry { description: String, path: PathBuf, enabled: bool, + is_bundled: bool, } #[derive(Debug, Serialize)] struct SkillsResponse { directory: PathBuf, + directories: Vec, warnings: Vec, skills: Vec, } @@ -906,20 +907,24 @@ async fn list_skills( State(state): State, ) -> Result, ApiError> { let skills_dir = resolve_skills_dir(&state.config, &state.workspace); - let registry = SkillRegistry::discover(&skills_dir); + let registry = + crate::skills::discover_for_workspace_and_dir(&state.workspace, &skills_dir); let skill_state = state.skill_state.lock().await; + let directories = crate::skills::skills_directories(&state.workspace); let skills = registry .list() .iter() .map(|skill| SkillEntry { name: skill.name.clone(), description: skill.description.clone(), - path: skills_dir.join(&skill.name).join("SKILL.md"), + path: skill.path.clone(), enabled: skill_state.is_enabled(&skill.name), + is_bundled: crate::skills::is_bundled_skill_name(&skill.name), }) .collect(); Ok(Json(SkillsResponse { directory: skills_dir, + directories, warnings: registry.warnings().to_vec(), skills, })) @@ -931,12 +936,12 @@ async fn set_skill_enabled( Json(req): Json, ) -> Result, ApiError> { let skills_dir = resolve_skills_dir(&state.config, &state.workspace); - let registry = SkillRegistry::discover(&skills_dir); + let registry = + crate::skills::discover_for_workspace_and_dir(&state.workspace, &skills_dir); let exists = registry.list().iter().any(|skill| skill.name == name); if !exists { return Err(ApiError::not_found(format!( - "skill '{name}' not found under {}", - skills_dir.display() + "skill '{name}' not found" ))); } From 6532a383148d9da96681818d3732b7b1d200ef13 Mon Sep 17 00:00:00 2001 From: AccMoment <1161677781@qq.com> Date: Wed, 27 May 2026 21:54:44 +0800 Subject: [PATCH 121/283] avoid dependency reverse reimplement validate_and_build_proxy and add test --- crates/cli/src/lib.rs | 41 ++++++++++++++++++++++++++++++++---- crates/cli/src/update.rs | 45 ++++++---------------------------------- 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 6d5cf17c..64b70ba6 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -240,6 +240,7 @@ struct UpdateArgs { /// Update to the latest beta release instead of the latest stable release. #[arg(long)] beta: bool, + /// Optional proxy URL to use for all update HTTP requests (e.g. `http://host:port` or `socks5://host:port`). #[arg(long)] proxy: Option, } @@ -571,7 +572,7 @@ fn run() -> Result<()> { Ok(()) } Some(Commands::Metrics(args)) => run_metrics_command(args), - Some(Commands::Update(args)) => update::run_update(args), + Some(Commands::Update(args)) => update::run_update(args.beta, args.proxy), None => { let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides); let forwarded = root_tui_passthrough(&cli)?; @@ -1684,6 +1685,7 @@ fn read_api_key_from_stdin() -> Result { #[cfg(test)] mod tests { use super::*; + use crate::update::validate_and_build_proxy; use clap::error::ErrorKind; use std::ffi::OsString; use std::sync::{Mutex, OnceLock}; @@ -2436,16 +2438,47 @@ mod tests { } #[test] - fn udpate_parse_with_proxy() { - let cli = parse_ok(&["deepseek", "update", "--proxy", "http:localhost:7897"]); + fn update_parse_with_proxy() { + let cli = parse_ok(&["deepseek", "update", "--proxy", "http://localhost:7897"]); let args = match cli.command { Some(Commands::Update(args)) => args, other => panic!("expected Update with proxy, got {other:?}"), }; + assert_eq!( args.proxy.expect("should have proxy"), - "http:localhost:7897" + "http://localhost:7897" + ); + + // Valid HTTP proxy + assert!( + validate_and_build_proxy("http://localhost:7897").is_ok(), + "valid HTTP proxy should succeed" + ); + + // Valid HTTPS proxy + assert!( + validate_and_build_proxy("https://proxy.example.com:8080").is_ok(), + "valid HTTPS proxy should succeed" + ); + + // Valid SOCKS5 proxy + assert!( + validate_and_build_proxy("socks5://127.0.0.1:1080").is_ok(), + "valid SOCKS5 proxy should succeed" + ); + + // Invalid: empty URL + assert!( + validate_and_build_proxy("").is_err(), + "empty proxy URL should fail" + ); + + // Invalid: malformed URL + assert!( + validate_and_build_proxy("not a valid url").is_err(), + "malformed proxy URL should fail" ); } diff --git a/crates/cli/src/update.rs b/crates/cli/src/update.rs index 44b26537..9fe6eebc 100644 --- a/crates/cli/src/update.rs +++ b/crates/cli/src/update.rs @@ -4,10 +4,8 @@ //! `github.com/Hmbown/CodeWhale/releases/latest`, downloads the //! platform-correct binary, verifies its SHA256 checksum, and atomically //! replaces the currently running binary. -use crate::UpdateArgs; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::time::Duration; use anyhow::{Context, Result, bail}; use reqwest::Proxy; @@ -28,16 +26,15 @@ const LEGACY_UPDATE_VERSION_ENV: &str = "DEEPSEEK_VERSION"; const UPDATE_USER_AGENT: &str = "codewhale-updater"; /// Run the self-update workflow. -pub fn run_update(args: UpdateArgs) -> Result<()> { - let beta = args.beta; +pub fn run_update(beta: bool, proxy_arg: Option) -> Result<()> { let current_exe = std::env::current_exe().context("failed to determine current executable path")?; let targets = update_targets_for_exe(¤t_exe); let channel = ReleaseChannel::from_beta_flag(beta); let current_version = env!("CARGO_PKG_VERSION"); - let proxy = if let Some(proxy_str) = &args.proxy { - validate_and_build_proxy(proxy_str)? + let proxy = if let Some(proxy_str) = &proxy_arg { + Some(validate_and_build_proxy(proxy_str)?) } else { None }; @@ -184,8 +181,8 @@ enum ReleaseSource { Mirror { base_url: String }, } -// Validate the proxy URL and optionally test connectivity before proceeding. -fn validate_and_build_proxy(proxy_str: &str) -> Result> { +/// Validate the proxy URL and optionally test connectivity before proceeding. +pub(crate) fn validate_and_build_proxy(proxy_str: &str) -> Result { let valid_url = reqwest::Url::parse(proxy_str).with_context(|| { format!( "invalid proxy URL: {proxy_str}\n\ @@ -194,37 +191,7 @@ fn validate_and_build_proxy(proxy_str: &str) -> Result> { })?; let proxy = reqwest::Proxy::all(valid_url)?; - - // Quick connectivity test through the proxy - let client = reqwest::blocking::Client::builder() - .proxy(proxy.clone()) - .user_agent(UPDATE_USER_AGENT) - .timeout(Duration::from_secs(10)) - .build() - .context("Could not build proxy HTTP client")?; - - match client.head(LATEST_RELEASE_URL).send() { - Ok(_) => Ok(Some(proxy)), - Err(e) => { - // Give a clear actionable error rather than a raw reqwest error - let hint = if e.is_timeout() || e.is_connect() { - "could not connect to the proxy server" - } else if e.is_request() { - "the request was sent but no response was received" - } else { - "an unexpected network error occurred" - }; - bail!( - "proxy connectivity failed: {hint}\n\ - Proxy URL: {proxy_str}\n\ - Details: {e}\n\ - Please verify:\n\ - - The proxy URL is correct\n\ - - The proxy server is running and reachable\n\ - - The proxy allows outbound connections to api.github.com" - ) - } - } + Ok(proxy) } pub(crate) fn release_arch_for_rust_arch(arch: &str) -> &str { From 1c570e00caffb1782b34acc64cf1c9c08a5ba48f Mon Sep 17 00:00:00 2001 From: AccMoment <1161677781@qq.com> Date: Wed, 27 May 2026 22:09:20 +0800 Subject: [PATCH 122/283] enable reqwest socks features --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index dae89151..465181d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ chrono = { version = "0.4.43", features = ["serde"] } clap = { version = "4.5.54", features = ["derive"] } clap_complete = "4.5" dirs = "6.0.0" -reqwest = { version = "0.13.1", default-features = false, features = ["json", "rustls"] } +reqwest = { version = "0.13.1", default-features = false, features = ["json", "rustls","socks"] } rusqlite = { version = "0.32.1", features = ["bundled"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" From a1e92cd6c2abe60a9752977921b365b9b88938ff Mon Sep 17 00:00:00 2001 From: Hu Qiantao Date: Wed, 27 May 2026 22:37:46 +0800 Subject: [PATCH 123/283] fix(tui): reset user_scrolled_during_stream on stall recovery Without this, the turn immediately after a stall recovery would inherit the scroll-lock from the stalled turn and silently skip auto-scroll, leaving the user staring at stale content. --- crates/tui/src/tui/ui.rs | 2 ++ crates/tui/src/tui/ui/tests.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index e73ec537..c53039e7 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3930,6 +3930,8 @@ fn reconcile_turn_liveness(app: &mut App, now: Instant, has_running_agents: bool app.runtime_turn_status = None; app.runtime_turn_id = None; app.dispatch_started_at = None; + // Per-turn scroll lock — clear so the next turn auto-scrolls. + app.user_scrolled_during_stream = false; app.push_status_toast( "Turn stalled — no completion signal received. Please try again.", StatusToastLevel::Error, diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index d41cf68b..5bc6fc8a 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -2040,6 +2040,7 @@ fn turn_liveness_recovers_stalled_in_progress_turn() { app.turn_started_at = Some(Instant::now() - TURN_STALL_WATCHDOG_TIMEOUT - Duration::from_millis(1)); app.streaming_message_index = Some(0); + app.user_scrolled_during_stream = true; let recovered = reconcile_turn_liveness(&mut app, Instant::now(), false); @@ -2051,6 +2052,7 @@ fn turn_liveness_recovers_stalled_in_progress_turn() { assert!(app.dispatch_started_at.is_none()); assert!(app.streaming_message_index.is_none()); assert!(app.streaming_thinking_active_entry.is_none()); + assert!(!app.user_scrolled_during_stream); let toast = app.status_toasts.back().expect("stall toast"); assert_eq!(toast.level, StatusToastLevel::Error); assert!(toast.text.contains("Turn stalled")); From ab192fe69c9faa9a2bf4318a1d2b391f6a829f3c Mon Sep 17 00:00:00 2001 From: LING71671 <1739677116@qq.com> Date: Wed, 27 May 2026 23:34:05 +0800 Subject: [PATCH 124/283] fix(config): normalize cost currency display comparison --- crates/tui/src/tui/views/mod.rs | 73 +++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 0b6a805f..26c381d5 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -1124,14 +1124,16 @@ impl ConfigView { } fn row_display_value(&self, row: &ConfigRow) -> String { - if row.key == "cost_currency" - && row.scope == ConfigScope::Saved - && row.value != self.effective_cost_currency - { - format!("{} (effective {})", row.value, self.effective_cost_currency) - } else { - row.value.clone() + if row.key == "cost_currency" && row.scope == ConfigScope::Saved { + let saved_cost_currency = crate::pricing::CostCurrency::from_setting(&row.value); + let effective_cost_currency = + crate::pricing::CostCurrency::from_setting(&self.effective_cost_currency); + if saved_cost_currency != effective_cost_currency { + return format!("{} (effective {})", row.value, self.effective_cost_currency); + } } + + row.value.clone() } } @@ -2120,6 +2122,26 @@ mod tests { App::new(options, &Config::default()) } + fn cost_currency_row_for_settings( + settings_toml: &str, + ) -> (String, String, crate::pricing::CostCurrency, Locale) { + let _guard = ConfigSettingsEnvGuard::new(settings_toml); + let app = create_test_app(); + let view = ConfigView::new_for_app(&app); + let row = view + .rows + .iter() + .find(|row| row.key == "cost_currency") + .expect("cost_currency row"); + + ( + row.value.clone(), + view.row_display_value(row), + app.cost_currency, + app.ui_locale, + ) + } + fn type_filter(view: &mut ConfigView, text: &str) { for ch in text.chars() { let action = view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); @@ -2319,6 +2341,43 @@ mod tests { assert_eq!(Settings::load().expect("settings").cost_currency, "usd"); } + #[test] + fn config_view_cost_currency_aliases_matching_effective_currency_are_silent() { + for alias in ["rmb", "yuan", "¥"] { + let (saved_value, display_value, effective_currency, locale) = + cost_currency_row_for_settings(&format!( + "locale = \"zh-Hans\"\ncost_currency = \"{alias}\"\n" + )); + + assert_eq!(locale, Locale::ZhHans); + assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny); + assert_eq!(saved_value, alias); + assert_eq!(display_value, alias); + } + } + + #[test] + fn config_view_cost_currency_matching_cny_setting_is_silent() { + let (saved_value, display_value, effective_currency, locale) = + cost_currency_row_for_settings("locale = \"zh-Hans\"\ncost_currency = \"cny\"\n"); + + assert_eq!(locale, Locale::ZhHans); + assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny); + assert_eq!(saved_value, "cny"); + assert_eq!(display_value, "cny"); + } + + #[test] + fn config_view_cost_currency_non_zh_hans_locale_uses_saved_currency() { + let (saved_value, display_value, effective_currency, locale) = + cost_currency_row_for_settings("locale = \"en\"\ncost_currency = \"cny\"\n"); + + assert_eq!(locale, Locale::En); + assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny); + assert_eq!(saved_value, "cny"); + assert_eq!(display_value, "cny"); + } + #[test] fn config_view_exposes_all_available_saved_settings() { let app = create_test_app(); From a964d86b4bc360a91d84eab18c0cfe1f58219f8c Mon Sep 17 00:00:00 2001 From: Zhuoran Deng Date: Sun, 24 May 2026 10:51:14 +0800 Subject: [PATCH 125/283] feat(runtime): restore mobile control page --- crates/tui/src/main.rs | 18 +- crates/tui/src/runtime_api.rs | 116 +++++- crates/tui/src/runtime_mobile.html | 543 +++++++++++++++++++++++++++++ docs/RUNTIME_API.md | 41 ++- 4 files changed, 703 insertions(+), 15 deletions(-) create mode 100644 crates/tui/src/runtime_mobile.html diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 99579eac..d0b7d649 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -572,6 +572,9 @@ struct ServeArgs { /// Start runtime HTTP/SSE API server #[arg(long)] http: bool, + /// Start runtime HTTP/SSE API server with the built-in mobile control page + #[arg(long)] + mobile: bool, /// Start ACP server over stdio for editor clients such as Zed #[arg(long)] acp: bool, @@ -926,28 +929,35 @@ async fn main() -> Result<()> { let workspace = cli.workspace.clone().unwrap_or_else(|| { std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) }); - let selected_modes = [args.mcp, args.http, args.acp] + let http_selected = args.http || args.mobile; + let selected_modes = [args.mcp, http_selected, args.acp] .into_iter() .filter(|selected| *selected) .count(); if selected_modes != 1 { - bail!("Choose exactly one server mode: --mcp, --http, or --acp"); + bail!("Choose exactly one server mode: --mcp, --http/--mobile, or --acp"); } if args.mcp { mcp_server::run_mcp_server(workspace) - } else if args.http { + } else if http_selected { let config = load_config_from_cli(&cli)?; let cors_origins = resolve_cors_origins(&config, &args.cors_origin); + let host = if args.mobile && args.host == "127.0.0.1" { + "0.0.0.0".to_string() + } else { + args.host + }; runtime_api::run_http_server( config, workspace, runtime_api::RuntimeApiOptions { - host: args.host, + host, port: args.port, workers: args.workers.clamp(1, 8), cors_origins, auth_token: args.auth_token, insecure_no_auth: args.insecure_no_auth, + mobile: args.mobile, }, ) .await diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 20110cc4..24e58046 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -3,7 +3,7 @@ use std::collections::HashSet; use std::convert::Infallible; use std::fs; -use std::net::SocketAddr; +use std::net::{SocketAddr, UdpSocket}; use std::path::PathBuf; use std::process::Command; use std::sync::Arc; @@ -14,6 +14,7 @@ use async_stream::stream; use axum::extract::{Path, Query, Request, State}; use axum::http::{HeaderValue, Method, StatusCode, header}; use axum::middleware::{self, Next}; +use axum::response::Html; use axum::response::sse::{Event as SseEvent, KeepAlive, Sse}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; @@ -60,6 +61,7 @@ pub struct RuntimeApiState { auth_required: bool, bind_host: String, bind_port: u16, + mobile_enabled: bool, } #[derive(Debug, Clone)] @@ -78,6 +80,8 @@ pub struct RuntimeApiOptions { pub auth_token: Option, /// Allow `/v1/*` routes without auth when no token is configured. pub insecure_no_auth: bool, + /// Enables the built-in mobile control page at `/mobile`. + pub mobile: bool, } impl Default for RuntimeApiOptions { @@ -89,6 +93,7 @@ impl Default for RuntimeApiOptions { cors_origins: Vec::new(), auth_token: None, insecure_no_auth: false, + mobile: false, } } } @@ -423,6 +428,7 @@ pub async fn run_http_server( auth_required: auth_enabled, bind_host: options.host.clone(), bind_port: options.port, + mobile_enabled: options.mobile, }; let app = build_router(state); @@ -445,6 +451,9 @@ pub async fn run_http_server( } else { println!("Runtime API auth: disabled by explicit insecure mode."); } + if options.mobile { + print_mobile_urls(addr, runtime_token.as_deref(), auth_enabled); + } let is_loopback = options.host == "127.0.0.1" || options.host == "::1"; if is_loopback { println!("Security: this server is local-first. Do not expose it to untrusted networks."); @@ -529,6 +538,8 @@ pub fn build_router(state: RuntimeApiState) -> Router { Router::new() .route("/health", get(health)) + .route("/mobile", get(mobile_page)) + .route("/mobile/", get(mobile_page)) .route("/v1/runtime/info", get(runtime_info)) .merge(api_routes) .layer(cors_layer(&state.cors_origins)) @@ -581,6 +592,51 @@ fn token_from_query(query: Option<&str>) -> Option<&str> { }) } +async fn mobile_page(State(state): State) -> Response { + if !state.mobile_enabled { + return ( + StatusCode::NOT_FOUND, + "mobile control is disabled; start with `codewhale serve --mobile`", + ) + .into_response(); + } + Html(MOBILE_HTML).into_response() +} + +fn print_mobile_urls(addr: SocketAddr, token: Option<&str>, auth_enabled: bool) { + println!("Mobile control page enabled."); + let token_query = if auth_enabled { + token + .filter(|token| !token.trim().is_empty()) + .map(|token| format!("?token={token}")) + .unwrap_or_default() + } else { + String::new() + }; + + let port = addr.port(); + if addr.ip().is_unspecified() { + println!(" Local: http://127.0.0.1:{port}/mobile{token_query}"); + if let Some(ip) = detect_lan_ip() { + println!(" LAN: http://{ip}:{port}/mobile{token_query}"); + } else { + println!( + " LAN: bind is 0.0.0.0; open http://:{port}/mobile{token_query}" + ); + } + } else { + println!(" URL: http://{addr}/mobile{token_query}"); + } + println!("Mobile security: use only on a trusted LAN/VPN; this server does not provide TLS."); +} + +fn detect_lan_ip() -> Option { + let socket = UdpSocket::bind("0.0.0.0:0").ok()?; + socket.connect("8.8.8.8:80").ok()?; + let addr = socket.local_addr().ok()?; + Some(addr.ip().to_string()) +} + async fn health() -> Json { Json(HealthResponse { status: "ok", @@ -1562,6 +1618,8 @@ fn map_compat_stream_event(event: &crate::runtime_threads::RuntimeEventRecord) - } } "approval.required" => Some(sse_json("approval.required", payload.clone())), + "approval.decided" => Some(sse_json("approval.decided", payload.clone())), + "approval.timeout" => Some(sse_json("approval.timeout", payload.clone())), "sandbox.denied" => Some(sse_json("sandbox.denied", payload.clone())), "turn.completed" => { let usage = payload @@ -1742,6 +1800,8 @@ async fn get_usage( Ok(Json(json!(aggregation))) } +const MOBILE_HTML: &str = include_str!("runtime_mobile.html"); + /// Built-in dev origins always allowed by the runtime API (whalescale#255). const DEFAULT_CORS_ORIGINS: &[&str] = &[ "http://localhost:3000", @@ -1973,6 +2033,21 @@ mod tests { SharedRuntimeThreadManager, tokio::task::JoinHandle<()>, )>, + > { + spawn_test_server_with_root_token_and_mobile(root, sessions_dir, runtime_token, false).await + } + + async fn spawn_test_server_with_root_token_and_mobile( + root: PathBuf, + sessions_dir: PathBuf, + runtime_token: Option, + mobile_enabled: bool, + ) -> Result< + Option<( + SocketAddr, + SharedRuntimeThreadManager, + tokio::task::JoinHandle<()>, + )>, > { fs::create_dir_all(&sessions_dir)?; let manager = TaskManager::start_with_executor( @@ -2035,6 +2110,7 @@ mod tests { auth_required, bind_host: "127.0.0.1".to_string(), bind_port: 0, + mobile_enabled, }; let app = build_router(state); let listener = match TcpListener::bind("127.0.0.1:0").await { @@ -3600,6 +3676,44 @@ mod tests { Ok(()) } + #[tokio::test] + async fn mobile_page_is_available_only_when_enabled() -> Result<()> { + let tmp = tempfile::tempdir()?; + let root = tmp.path().to_path_buf(); + let sessions_dir = root.join("sessions"); + let Some((addr, _runtime_threads, handle)) = spawn_test_server_with_root_token_and_mobile( + root.clone(), + sessions_dir.clone(), + None, + false, + ) + .await? + else { + return Ok(()); + }; + let client = reqwest::Client::new(); + let disabled = client.get(format!("http://{addr}/mobile")).send().await?; + assert_eq!(disabled.status(), StatusCode::NOT_FOUND); + handle.abort(); + + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_root_token_and_mobile(root, sessions_dir, None, true).await? + else { + return Ok(()); + }; + let enabled = client + .get(format!("http://{addr}/mobile")) + .send() + .await? + .error_for_status()?; + let html = enabled.text().await?; + assert!(html.contains("CodeWhale Mobile")); + assert!(html.contains("/v1/approvals/")); + + handle.abort(); + Ok(()) + } + #[tokio::test] async fn decide_approval_404s_when_nothing_pending() -> Result<()> { let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { diff --git a/crates/tui/src/runtime_mobile.html b/crates/tui/src/runtime_mobile.html new file mode 100644 index 00000000..f7ca0af0 --- /dev/null +++ b/crates/tui/src/runtime_mobile.html @@ -0,0 +1,543 @@ + + + + + + CodeWhale Mobile + + + +
+

CodeWhale Mobile

+ +
+ +
+
+
+ Connection + +
+
+ +
Not connected
+
+
+ +
+
+ Threads + +
+
+
+ +
+
+ No thread selected + 0 events +
+
+
+ +
+
+ Composer + +
+
+ +
+ + + +
+
+ + +
+
+
+
+ + + + diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index 504154f0..3fdfb7a9 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -117,6 +117,7 @@ codewhale doctor --json ```bash codewhale serve --http [--host 127.0.0.1] [--port 7878] [--workers 2] [--auth-token TOKEN] +codewhale serve --mobile [--port 7878] [--auth-token TOKEN] ``` Defaults: host `127.0.0.1`, port `7878`, 2 workers (clamped 1–8). @@ -124,16 +125,30 @@ Defaults: host `127.0.0.1`, port `7878`, 2 workers (clamped 1–8). The server binds to `localhost` by default. Configuration is via CLI flags — there is no `[app_server]` config section. -By default, existing local behavior is unchanged and `/v1/*` routes are not -authenticated. To require a bearer token for `/v1/*` routes, pass -`--auth-token TOKEN` or set `DEEPSEEK_RUNTIME_TOKEN=TOKEN` before starting the -server. `/health` remains public for local process supervision and readiness -checks. +`/v1/*` routes require a bearer token unless `--insecure` is explicitly set. +Pass `--auth-token TOKEN` or set `DEEPSEEK_RUNTIME_TOKEN=TOKEN` before starting +the server. If neither is set, the process generates a one-time token and prints +it at startup. `/health`, `/mobile`, and `/v1/runtime/info` remain public for +local supervision and bootstrap. Authenticated clients can provide the token as `Authorization: Bearer TOKEN`, `X-DeepSeek-Runtime-Token: TOKEN`, or `?token=TOKEN` for EventSource-style clients that cannot set custom headers. +### Mobile control page + +`codewhale serve --mobile` starts the same HTTP/SSE runtime API and serves a +phone-friendly control page at `/mobile`. When the bind host is left at the +default, mobile mode binds to `0.0.0.0` and prints local/LAN URLs. If a runtime +token is generated or supplied, the printed mobile URL includes it as a query +parameter; the page stores it locally and removes it from the address bar. + +The mobile page can list/create threads, send prompts, follow live SSE events, +steer or interrupt an active turn, and resolve normal tool approvals through +`POST /v1/approvals/{approval_id}`. It is still a local/LAN convenience surface: +do not expose it directly to the public internet without TLS and a trusted +fronting layer. + ### Endpoints **Health** @@ -188,6 +203,10 @@ accept an empty string to clear a previously-set value. Added in v0.8.10 (#562): - `POST /v1/threads/{id}/turns/{turn_id}/interrupt` - `POST /v1/threads/{id}/compact` (manual compaction) +**Approvals** +- `POST /v1/approvals/{approval_id}` with body + `{ "decision": "allow" | "deny", "remember": false }` + **Events** (SSE replay + live stream) - `GET /v1/threads/{id}/events?since_seq=` @@ -306,14 +325,16 @@ The SSE event payload shape: Common event names: `thread.started`, `thread.forked`, `turn.started`, `turn.lifecycle`, `turn.steered`, `turn.interrupt_requested`, `turn.completed`, `item.started`, `item.delta`, `item.completed`, -`item.failed`, `item.interrupted`, `approval.required`, `sandbox.denied`, -`coherence.state`. +`item.failed`, `item.interrupted`, `approval.required`, `approval.decided`, +`approval.timeout`, `sandbox.denied`, `coherence.state`. ## Security boundary -- **Localhost only**. The server binds to `127.0.0.1` by default. Set - `--host 0.0.0.0` only when you have a reverse-proxy / VPN that - authenticates. The runtime does not provide user isolation or TLS. +- **Localhost by default**. The server binds to `127.0.0.1` by default. + `--mobile` binds to `0.0.0.0` when the host is left at the default so phones + on the same LAN can reach it. Set a non-loopback host only when you trust the + network path or have a reverse-proxy / VPN that authenticates. The runtime + does not provide user isolation or TLS. - **Optional token guard**. `--auth-token` or `DEEPSEEK_RUNTIME_TOKEN` requires a matching bearer token for `/v1/*` routes. This is a local convenience guard, not a replacement for TLS, VPN, or a trusted reverse From bf898a4d3d9a4b09afbada7a410039ac30f03680 Mon Sep 17 00:00:00 2001 From: Zhuoran Deng Date: Sun, 24 May 2026 11:01:40 +0800 Subject: [PATCH 126/283] fix(runtime): avoid public DNS LAN probe --- crates/tui/src/runtime_api.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 24e58046..4d60a5e2 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -632,7 +632,8 @@ fn print_mobile_urls(addr: SocketAddr, token: Option<&str>, auth_enabled: bool) fn detect_lan_ip() -> Option { let socket = UdpSocket::bind("0.0.0.0:0").ok()?; - socket.connect("8.8.8.8:80").ok()?; + // UDP connect only selects the outbound interface locally; no packet is sent. + socket.connect("10.255.255.255:1").ok()?; let addr = socket.local_addr().ok()?; Some(addr.ip().to_string()) } From 58b32dabf95546a5d5da025ff9767076c023722d Mon Sep 17 00:00:00 2001 From: Zhuoran Deng Date: Sun, 24 May 2026 11:04:31 +0800 Subject: [PATCH 127/283] fix(runtime): encode mobile auth token in URL --- crates/tui/src/runtime_api.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 4d60a5e2..23b26085 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -608,7 +608,7 @@ fn print_mobile_urls(addr: SocketAddr, token: Option<&str>, auth_enabled: bool) let token_query = if auth_enabled { token .filter(|token| !token.trim().is_empty()) - .map(|token| format!("?token={token}")) + .map(|token| format!("?token={}", url_query_component(token))) .unwrap_or_default() } else { String::new() @@ -630,6 +630,22 @@ fn print_mobile_urls(addr: SocketAddr, token: Option<&str>, auth_enabled: bool) println!("Mobile security: use only on a trusted LAN/VPN; this server does not provide TLS."); } +fn url_query_component(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + encoded.push(byte as char); + } + _ => { + use std::fmt::Write as _; + let _ = write!(encoded, "%{byte:02X}"); + } + } + } + encoded +} + fn detect_lan_ip() -> Option { let socket = UdpSocket::bind("0.0.0.0:0").ok()?; // UDP connect only selects the outbound interface locally; no packet is sent. @@ -2011,6 +2027,14 @@ mod tests { assert!(auth.token.is_some()); } + #[test] + fn url_query_component_percent_encodes_token() { + assert_eq!( + url_query_component("abc ABC+/?:=&%"), + "abc%20ABC%2B%2F%3F%3A%3D%26%25" + ); + } + async fn spawn_test_server_with_root( root: PathBuf, sessions_dir: PathBuf, From c8c5e521680d02b43a6813ac08f1afdee5b6741a Mon Sep 17 00:00:00 2001 From: Zhuoran Deng Date: Thu, 28 May 2026 06:42:22 +0800 Subject: [PATCH 128/283] fix(runtime): tighten mobile control security --- README.md | 3 +- README.zh-CN.md | 3 +- crates/tui/src/main.rs | 80 +++++++++++++++-- crates/tui/src/runtime_api.rs | 163 +++++++++++++++++++++++++++++----- docs/RUNTIME_API.md | 26 +++--- 5 files changed, 233 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 3213ee15..dc339fd4 100644 --- a/README.md +++ b/README.md @@ -372,6 +372,7 @@ codewhale resume --last # resume the most recent sessi codewhale resume # resume a specific session by UUID codewhale fork # fork a saved session into a sibling path codewhale serve --http # HTTP/SSE API server +codewhale serve --mobile # LAN mobile control page; token-gated by default codewhale serve --acp # ACP stdio adapter for Zed/custom agents codewhale run pr # fetch PR and pre-seed review prompt codewhale mcp list # list configured MCP servers @@ -557,7 +558,7 @@ without recreating skills the user deliberately deleted. | [CONFIGURATION.md](docs/CONFIGURATION.md) | Full config reference | | [MODES.md](docs/MODES.md) | Plan / Agent / YOLO modes | | [MCP.md](docs/MCP.md) | Model Context Protocol integration | -| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API server | +| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API server and mobile control page | | [INSTALL.md](docs/INSTALL.md) | Platform-specific install guide | | [DOCKER.md](docs/DOCKER.md) | GHCR image, volumes, and Docker usage | | [CNB_MIRROR.md](docs/CNB_MIRROR.md) | CNB mirror and China-friendly install notes | diff --git a/README.zh-CN.md b/README.zh-CN.md index f079cc80..78f4f3b5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -316,6 +316,7 @@ codewhale resume --last # 恢复最近会话 codewhale resume # 按 UUID 恢复指定会话 codewhale fork # 将已保存会话分叉为兄弟路径 codewhale serve --http # HTTP/SSE API 服务 +codewhale serve --mobile # 局域网移动端控制页,默认启用 token 保护 codewhale serve --acp # Zed/自定义智能体的 ACP stdio 适配器 codewhale run pr # 获取 PR 并预填审查提示 codewhale mcp list # 列出已配置 MCP 服务器 @@ -494,7 +495,7 @@ description: 当 DeepSeek 需要遵循我的自定义工作流时使用这个技 | [CONFIGURATION.md](docs/CONFIGURATION.md) | 完整配置参考 | | [MODES.md](docs/MODES.md) | Plan / Agent / YOLO 模式 | | [MCP.md](docs/MCP.md) | Model Context Protocol 集成 | -| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API 服务 | +| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API 服务和移动端控制页 | | [INSTALL.md](docs/INSTALL.md) | 各平台安装指南 | | [DOCKER.md](docs/DOCKER.md) | GHCR 镜像、volume 和 Docker 用法 | | [CNB_MIRROR.md](docs/CNB_MIRROR.md) | CNB 镜像和中国大陆友好安装说明 | diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index d0b7d649..b6c859ea 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -578,9 +578,9 @@ struct ServeArgs { /// Start ACP server over stdio for editor clients such as Zed #[arg(long)] acp: bool, - /// Bind host for HTTP server (default localhost) - #[arg(long, default_value = "127.0.0.1")] - host: String, + /// Bind host for HTTP server (default localhost; --mobile defaults to 0.0.0.0) + #[arg(long)] + host: Option, /// Bind port for HTTP server #[arg(long, default_value_t = 7878)] port: u16, @@ -602,6 +602,29 @@ struct ServeArgs { insecure_no_auth: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct ServeBindHost { + host: String, + mobile_rebound_to_lan: bool, +} + +fn resolve_serve_bind_host(mobile: bool, host: Option) -> ServeBindHost { + match (mobile, host) { + (true, None) => ServeBindHost { + host: "0.0.0.0".to_string(), + mobile_rebound_to_lan: true, + }, + (_, Some(host)) => ServeBindHost { + host, + mobile_rebound_to_lan: false, + }, + (false, None) => ServeBindHost { + host: "127.0.0.1".to_string(), + mobile_rebound_to_lan: false, + }, + } +} + #[derive(Subcommand, Debug, Clone)] enum McpCommand { /// List configured MCP servers @@ -942,16 +965,17 @@ async fn main() -> Result<()> { } else if http_selected { let config = load_config_from_cli(&cli)?; let cors_origins = resolve_cors_origins(&config, &args.cors_origin); - let host = if args.mobile && args.host == "127.0.0.1" { - "0.0.0.0".to_string() - } else { - args.host - }; + let bind_host = resolve_serve_bind_host(args.mobile, args.host); + if bind_host.mobile_rebound_to_lan { + println!( + "WARNING: --mobile is binding to 0.0.0.0 so LAN devices can reach the mobile control page. Use --host 127.0.0.1 to keep mobile loopback-only." + ); + } runtime_api::run_http_server( config, workspace, runtime_api::RuntimeApiOptions { - host, + host: bind_host.host, port: args.port, workers: args.workers.clamp(1, 8), cors_origins, @@ -5581,6 +5605,44 @@ async fn run_exec_agent( Ok(()) } +#[cfg(test)] +mod serve_bind_host_tests { + use super::*; + + #[test] + fn http_defaults_to_loopback() { + assert_eq!( + resolve_serve_bind_host(false, None), + ServeBindHost { + host: "127.0.0.1".to_string(), + mobile_rebound_to_lan: false, + } + ); + } + + #[test] + fn mobile_default_rebinds_to_lan_with_warning_flag() { + assert_eq!( + resolve_serve_bind_host(true, None), + ServeBindHost { + host: "0.0.0.0".to_string(), + mobile_rebound_to_lan: true, + } + ); + } + + #[test] + fn mobile_respects_explicit_loopback_host() { + assert_eq!( + resolve_serve_bind_host(true, Some("127.0.0.1".to_string())), + ServeBindHost { + host: "127.0.0.1".to_string(), + mobile_rebound_to_lan: false, + } + ); + } +} + #[cfg(test)] mod doctor_endpoint_tests { use super::*; diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 23b26085..34960ffa 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -554,8 +554,17 @@ async fn require_runtime_token( let Some(expected) = state.runtime_token.as_deref() else { return next.run(req).await; }; - let authorized = req - .headers() + let authorized = request_has_runtime_token(&req, expected); + + if authorized { + next.run(req).await + } else { + runtime_token_required_response() + } +} + +fn request_has_runtime_token(req: &Request, expected: &str) -> bool { + req.headers() .get(header::AUTHORIZATION) .and_then(|value| value.to_str().ok()) .and_then(|raw| raw.strip_prefix("Bearer ")) @@ -565,34 +574,61 @@ async fn require_runtime_token( .get("x-deepseek-runtime-token") .and_then(|value| value.to_str().ok()) .is_some_and(|token| token == expected) - || token_from_query(req.uri().query()).is_some_and(|token| token == expected); - - if authorized { - next.run(req).await - } else { - ( - StatusCode::UNAUTHORIZED, - Json(json!({ - "error": { - "message": "runtime API bearer token required", - "status": StatusCode::UNAUTHORIZED.as_u16(), - } - })), - ) - .into_response() - } + || token_from_query(req.uri().query()).is_some_and(|token| token == expected) } -fn token_from_query(query: Option<&str>) -> Option<&str> { +fn runtime_token_required_response() -> Response { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": { + "message": "runtime API bearer token required", + "status": StatusCode::UNAUTHORIZED.as_u16(), + } + })), + ) + .into_response() +} + +fn token_from_query(query: Option<&str>) -> Option { query.and_then(|query| { query.split('&').find_map(|pair| { let (key, value) = pair.split_once('=')?; - (key == "token").then_some(value) + (key == "token") + .then(|| percent_decode_query_component(value)) + .flatten() }) }) } -async fn mobile_page(State(state): State) -> Response { +fn percent_decode_query_component(value: &str) -> Option { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'%' => { + let hi = *bytes.get(index + 1)?; + let lo = *bytes.get(index + 2)?; + let hi = (hi as char).to_digit(16)? as u8; + let lo = (lo as char).to_digit(16)? as u8; + decoded.push((hi << 4) | lo); + index += 3; + } + b'+' => { + decoded.push(b' '); + index += 1; + } + byte => { + decoded.push(byte); + index += 1; + } + } + } + String::from_utf8(decoded).ok() +} + +async fn mobile_page(State(state): State, req: Request) -> Response { if !state.mobile_enabled { return ( StatusCode::NOT_FOUND, @@ -600,6 +636,11 @@ async fn mobile_page(State(state): State) -> Response { ) .into_response(); } + if let Some(expected) = state.runtime_token.as_deref() + && !request_has_runtime_token(&req, expected) + { + return runtime_token_required_response(); + } Html(MOBILE_HTML).into_response() } @@ -2035,6 +2076,15 @@ mod tests { ); } + #[test] + fn token_from_query_decodes_percent_encoded_token() { + assert_eq!( + token_from_query(Some("since_seq=0&token=abc%20ABC%2B%2F%3F%3A%3D%26%25")), + Some("abc ABC+/?:=&%".to_string()) + ); + assert_eq!(token_from_query(Some("token=bad%ZZ")), None); + } + async fn spawn_test_server_with_root( root: PathBuf, sessions_dir: PathBuf, @@ -3739,6 +3789,77 @@ mod tests { Ok(()) } + #[tokio::test] + async fn mobile_page_requires_runtime_token_when_auth_enabled() -> Result<()> { + let tmp = tempfile::tempdir()?; + let root = tmp.path().to_path_buf(); + let sessions_dir = root.join("sessions"); + let token = "abc ABC+/?:=&%".to_string(); + let Some((addr, _runtime_threads, handle)) = spawn_test_server_with_root_token_and_mobile( + root, + sessions_dir, + Some(token.clone()), + true, + ) + .await? + else { + return Ok(()); + }; + let client = reqwest::Client::new(); + + let unauthorized = client.get(format!("http://{addr}/mobile")).send().await?; + assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED); + + let encoded = url_query_component(&token); + let query = client + .get(format!("http://{addr}/mobile?token={encoded}")) + .send() + .await? + .error_for_status()?; + assert!(query.text().await?.contains("CodeWhale Mobile")); + + let bearer = client + .get(format!("http://{addr}/mobile")) + .bearer_auth(&token) + .send() + .await? + .error_for_status()?; + assert!(bearer.text().await?.contains("CodeWhale Mobile")); + + handle.abort(); + Ok(()) + } + + #[tokio::test] + async fn mobile_insecure_mode_allows_page_and_v1_routes_without_token() -> Result<()> { + let tmp = tempfile::tempdir()?; + let root = tmp.path().to_path_buf(); + let sessions_dir = root.join("sessions"); + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_root_token_and_mobile(root, sessions_dir, None, true).await? + else { + return Ok(()); + }; + let client = reqwest::Client::new(); + + let page = client + .get(format!("http://{addr}/mobile")) + .send() + .await? + .error_for_status()?; + assert!(page.text().await?.contains("CodeWhale Mobile")); + + let summary = client + .get(format!("http://{addr}/v1/threads/summary")) + .send() + .await? + .error_for_status()?; + assert_eq!(summary.status(), StatusCode::OK); + + handle.abort(); + Ok(()) + } + #[tokio::test] async fn decide_approval_404s_when_nothing_pending() -> Result<()> { let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index 3fdfb7a9..d7ec8331 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -117,7 +117,7 @@ codewhale doctor --json ```bash codewhale serve --http [--host 127.0.0.1] [--port 7878] [--workers 2] [--auth-token TOKEN] -codewhale serve --mobile [--port 7878] [--auth-token TOKEN] +codewhale serve --mobile [--host 0.0.0.0] [--port 7878] [--auth-token TOKEN] ``` Defaults: host `127.0.0.1`, port `7878`, 2 workers (clamped 1–8). @@ -128,8 +128,10 @@ there is no `[app_server]` config section. `/v1/*` routes require a bearer token unless `--insecure` is explicitly set. Pass `--auth-token TOKEN` or set `DEEPSEEK_RUNTIME_TOKEN=TOKEN` before starting the server. If neither is set, the process generates a one-time token and prints -it at startup. `/health`, `/mobile`, and `/v1/runtime/info` remain public for -local supervision and bootstrap. +it at startup. `/health` and `/v1/runtime/info` remain public for local +supervision and bootstrap. `/mobile` returns 404 when mobile mode is disabled; +when mobile mode is enabled and auth is enabled, `/mobile` returns 401 unless +the request supplies the runtime token. Authenticated clients can provide the token as `Authorization: Bearer TOKEN`, `X-DeepSeek-Runtime-Token: TOKEN`, or `?token=TOKEN` for EventSource-style @@ -139,9 +141,12 @@ clients that cannot set custom headers. `codewhale serve --mobile` starts the same HTTP/SSE runtime API and serves a phone-friendly control page at `/mobile`. When the bind host is left at the -default, mobile mode binds to `0.0.0.0` and prints local/LAN URLs. If a runtime -token is generated or supplied, the printed mobile URL includes it as a query -parameter; the page stores it locally and removes it from the address bar. +default, mobile mode binds to `0.0.0.0`, prints a warning, and prints local/LAN +URLs. Pass `--host 127.0.0.1` to keep the mobile page loopback-only. If a +runtime token is generated or supplied, the printed mobile URL includes it as a +query parameter; the page stores it locally and removes it from the address bar. +The static HTML page contains no secrets, but it is still token-gated when auth +is enabled so unauthenticated LAN clients cannot fingerprint the mobile surface. The mobile page can list/create threads, send prompts, follow live SSE events, steer or interrupt an active turn, and resolve normal tool approvals through @@ -331,10 +336,11 @@ Common event names: `thread.started`, `thread.forked`, `turn.started`, ## Security boundary - **Localhost by default**. The server binds to `127.0.0.1` by default. - `--mobile` binds to `0.0.0.0` when the host is left at the default so phones - on the same LAN can reach it. Set a non-loopback host only when you trust the - network path or have a reverse-proxy / VPN that authenticates. The runtime - does not provide user isolation or TLS. + `--mobile` binds to `0.0.0.0` when no host is supplied so phones on the same + LAN can reach it, and the CLI prints a warning for that rebind. Pass + `--host 127.0.0.1` for a loopback-only mobile page. Set a non-loopback host + only when you trust the network path or have a reverse-proxy / VPN that + authenticates. The runtime does not provide user isolation or TLS. - **Optional token guard**. `--auth-token` or `DEEPSEEK_RUNTIME_TOKEN` requires a matching bearer token for `/v1/*` routes. This is a local convenience guard, not a replacement for TLS, VPN, or a trusted reverse From 39adb5366073ee3f60f7da3b254f21fa5692ed8d Mon Sep 17 00:00:00 2001 From: Zhuoran Deng Date: Thu, 28 May 2026 06:54:42 +0800 Subject: [PATCH 129/283] fix(runtime): handle mobile review edge cases --- crates/tui/src/main.rs | 34 +++++++++++++++++++++++------- crates/tui/src/runtime_mobile.html | 5 +++-- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index b6c859ea..6111fd25 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -625,6 +625,21 @@ fn resolve_serve_bind_host(mobile: bool, host: Option) -> ServeBindHost } } +fn validate_serve_mode_selection(mcp: bool, http: bool, mobile: bool, acp: bool) -> Result { + if http && mobile { + bail!("--http and --mobile are mutually exclusive; choose one"); + } + let http_selected = http || mobile; + let selected_modes = [mcp, http_selected, acp] + .into_iter() + .filter(|selected| *selected) + .count(); + if selected_modes != 1 { + bail!("Choose exactly one server mode: --mcp, --http/--mobile, or --acp"); + } + Ok(http_selected) +} + #[derive(Subcommand, Debug, Clone)] enum McpCommand { /// List configured MCP servers @@ -952,14 +967,8 @@ async fn main() -> Result<()> { let workspace = cli.workspace.clone().unwrap_or_else(|| { std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) }); - let http_selected = args.http || args.mobile; - let selected_modes = [args.mcp, http_selected, args.acp] - .into_iter() - .filter(|selected| *selected) - .count(); - if selected_modes != 1 { - bail!("Choose exactly one server mode: --mcp, --http/--mobile, or --acp"); - } + let http_selected = + validate_serve_mode_selection(args.mcp, args.http, args.mobile, args.acp)?; if args.mcp { mcp_server::run_mcp_server(workspace) } else if http_selected { @@ -5641,6 +5650,15 @@ mod serve_bind_host_tests { } ); } + + #[test] + fn http_and_mobile_are_mutually_exclusive() { + let err = validate_serve_mode_selection(false, true, true, false).unwrap_err(); + assert!( + err.to_string() + .contains("--http and --mobile are mutually exclusive") + ); + } } #[cfg(test)] diff --git a/crates/tui/src/runtime_mobile.html b/crates/tui/src/runtime_mobile.html index f7ca0af0..cf8d6e44 100644 --- a/crates/tui/src/runtime_mobile.html +++ b/crates/tui/src/runtime_mobile.html @@ -275,7 +275,7 @@ } function token() { - return $("token").value.trim(); + return $("token").value; } function takeTokenFromUrl() { @@ -368,7 +368,8 @@ method: "POST", body: JSON.stringify({ decision, remember }) }); - container.innerHTML = "Decision sent: " + escapeHtml(result.decision) + ""; + const decided = result?.decision ?? decision; + container.innerHTML = "Decision sent: " + escapeHtml(decided) + ""; } function appendEvent(name, data) { From 9943fe537daf62f921fd9f37a5be26c6c91c2a24 Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Thu, 28 May 2026 11:46:15 +0800 Subject: [PATCH 130/283] feat: add export redaction + completion-gate verifier hook for SlopLedger (#2127) --- crates/tui/src/slop_ledger.rs | 105 ++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/crates/tui/src/slop_ledger.rs b/crates/tui/src/slop_ledger.rs index bc3a4f3a..7a0f6048 100644 --- a/crates/tui/src/slop_ledger.rs +++ b/crates/tui/src/slop_ledger.rs @@ -461,6 +461,7 @@ impl SlopLedger { } } + redact_exported_text(&mut out); out } @@ -491,6 +492,7 @@ impl SlopLedger { for (bucket, count) in &by_bucket { out.push_str(&format!(" {bucket}: {count}\n")); } + redact_exported_text(&mut out); out } } @@ -910,6 +912,109 @@ fn truncate_str(s: &str, max_chars: usize) -> String { format!("{truncated}…") } +/// Redact sensitive patterns from exported text: API keys and secrets +/// paths. Scan the output for known key prefixes (`sk-`, `Bearer `, `dsk-`) +/// and replace the token until a whitespace / punctuation boundary with +/// `[REDACTED]`. Also normalises fully-qualified secrets directory paths +/// to the portable `~/.codewhale/secrets` form. +fn redact_exported_text(text: &mut String) { + let prefixes: &[&[u8]] = &[b"sk-", b"Bearer ", b"dsk-", b"deepseek-"]; + let mut result = String::with_capacity(text.len()); + let bytes = text.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + let mut matched = false; + for prefix in prefixes { + if bytes[i..].len() >= prefix.len() + && bytes[i..i + prefix.len()].eq_ignore_ascii_case(prefix) + { + // Scan forward to first whitespace or delimiter. + let end = bytes[i + prefix.len()..] + .iter() + .position(|b| b.is_ascii_whitespace() || *b == b',' || *b == b';') + .map(|p| i + prefix.len() + p) + .unwrap_or(bytes.len()); + result.push_str("[REDACTED]"); + i = end; + matched = true; + break; + } + } + if !matched { + // Advance by one char (preserving multi-byte UTF-8 safety). + let ch = text[i..].chars().next().unwrap(); + result.push(ch); + i += ch.len_utf8(); + } + } + + // Normalise secrets directory paths. + if let Some(home) = dirs::home_dir() { + for leaf in [".codewhale/secrets", ".deepseek/secrets"] { + let dir = home.join(leaf); + let prefix = dir.to_string_lossy().to_string(); + result = result.replace(&prefix, "~/.codewhale/secrets"); + } + } + *text = result; +} + +impl SlopLedger { + /// Completion-gate / verifier hook: returns `true` when there are + /// unresolved slop entries (status `Open` or `Investigate`) that the + /// agent should review before claiming the task is done. + /// + /// Tools and engine hooks can call this on claim-of-done to surface + /// architectural residue the agent may have overlooked. + #[allow(dead_code)] + #[must_use] + pub fn has_open_entries(&self) -> bool { + self.entries.iter().any(|e| { + matches!( + e.status, + SlopEntryStatus::Open | SlopEntryStatus::InProgress + ) + }) + } + + /// Return a concise completion-gate summary suitable for a verifier + /// sub-agent or the claim-of-done prompt. Returns `None` when all + /// entries are resolved — the caller can then treat the gate as "pass". + #[allow(dead_code)] + #[must_use] + pub fn completion_gate_summary(&self) -> Option { + let open: Vec<&SlopEntry> = self + .entries + .iter() + .filter(|e| { + matches!( + e.status, + SlopEntryStatus::Open | SlopEntryStatus::InProgress + ) + }) + .collect(); + if open.is_empty() { + return None; + } + let mut out = format!( + "## ⚠️ SlopLedger gate — {} open slop entries\n\n", + open.len() + ); + out.push_str("Review these before claiming completion:\n\n"); + for e in open { + out.push_str(&format!( + "- **{}** `{}` ({:?}/{:?}): {}\n", + e.bucket.as_str(), + &e.id[..8], + e.severity, + e.confidence, + truncate_str(&e.title, 80), + )); + } + Some(out) + } +} + // ── Tests ────────────────────────────────────────────────────────────────── #[cfg(test)] From e9026814be51a95a5236f35e0601dbe7c9e54599 Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Thu, 28 May 2026 11:53:25 +0800 Subject: [PATCH 131/283] =?UTF-8?q?feat:=20integrate=20SlopLedger=20comple?= =?UTF-8?q?tion-gate=20into=20turn=20loop=20=E2=80=94=20auto-check=20on=20?= =?UTF-8?q?every=20completed=20turn=20(#2127)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tui/src/tui/ui.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 61cb195c..1512c5bb 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1491,6 +1491,27 @@ async fn run_event_loop( // Generate post-turn receipt for completed turns. if status == crate::core::events::TurnOutcomeStatus::Completed { + // SlopLedger completion-gate: after every completed + // turn, check whether there are unresolved slop entries + // the agent should address before claiming the task is + // done (#2127). This runs autonomously — no tool call + // required — so the agent can't forget to check. + if let Ok(ledger) = crate::slop_ledger::SlopLedger::load() + && ledger.has_open_entries() + { + if let Some(gate_msg) = ledger.completion_gate_summary() { + let short = gate_msg + .lines() + .nth(4) + .unwrap_or("review before done"); + app.push_status_toast( + format!("⚠️ SlopLedger: {short}"), + crate::tui::app::StatusToastLevel::Warning, + Some(12_000), + ); + } + } + let tool_count = app.tool_evidence.len(); let mut receipt = "✓ turn completed".to_string(); if tool_count > 0 { From ff1a8cd44b5666355785ccf99fdf8390406b4bda Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Thu, 28 May 2026 11:55:36 +0800 Subject: [PATCH 132/283] style: cargo fmt fix --- crates/tui/src/tui/ui.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 1512c5bb..311861c3 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1500,10 +1500,8 @@ async fn run_event_loop( && ledger.has_open_entries() { if let Some(gate_msg) = ledger.completion_gate_summary() { - let short = gate_msg - .lines() - .nth(4) - .unwrap_or("review before done"); + let short = + gate_msg.lines().nth(4).unwrap_or("review before done"); app.push_status_toast( format!("⚠️ SlopLedger: {short}"), crate::tui::app::StatusToastLevel::Warning, From a73da589519e2b88c5e5fc26f43b0304eae73e78 Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Thu, 28 May 2026 12:02:19 +0800 Subject: [PATCH 133/283] =?UTF-8?q?feat:=20inject=20SlopLedger=20gate=20in?= =?UTF-8?q?to=20system=20prompt=20=E2=80=94=20agent=20sees=20open=20entrie?= =?UTF-8?q?s=20every=20turn=20(#2127)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tui/src/core/engine.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index f98f523c..6d3bfd78 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1840,8 +1840,23 @@ impl Engine { }, self.session.approval_mode, ); - let stable_prompt = + let mut stable_prompt = merge_system_prompts(Some(&base), self.session.compaction_summary_prompt.clone()); + + // SlopLedger completion-gate: inject unresolved slop entries into the + // system prompt so the agent can autonomously review them before claiming + // the task is done (#2127). Only active when entries actually exist. + if let Ok(ledger) = crate::slop_ledger::SlopLedger::load() { + if ledger.has_open_entries() { + if let Some(gate_block) = ledger.completion_gate_summary() { + if let Some(SystemPrompt::Text(prompt_text)) = &mut stable_prompt { + prompt_text.push_str("\n\n"); + prompt_text.push_str(&gate_block); + } + } + } + } + let stable_hash = system_prompt_hash(stable_prompt.as_ref()); if self.session.system_prompt_override { self.session.last_system_prompt_hash = Some(stable_hash); From 721a9797555bfaf7a27c7cc629bd479f693ce82b Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Thu, 28 May 2026 12:21:51 +0800 Subject: [PATCH 134/283] perf: cache SlopLedger gate in engine to avoid disk I/O on every turn (#2127) --- crates/tui/src/core/engine.rs | 40 ++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 6d3bfd78..b8070b25 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -331,6 +331,11 @@ pub struct Engine { /// Diagnostics collected during the current step's tool calls. Drained /// and forwarded as a synthetic user message before the next API call. pending_lsp_blocks: Vec, + /// Cached SlopLedger gate block so `refresh_system_prompt` doesn't hit + /// the filesystem on every turn (#2127). `None` = not yet loaded; + /// `Some(None)` = loaded, no open entries; `Some(Some(...))` = loaded, + /// gate block ready. + slop_ledger_gate_cache: Option>, } // === Internal tool helpers === @@ -564,6 +569,7 @@ impl Engine { turn_counter: 0, lsp_manager, pending_lsp_blocks: Vec::new(), + slop_ledger_gate_cache: None, workshop_vars, sandbox_backend, }; @@ -1844,16 +1850,30 @@ impl Engine { merge_system_prompts(Some(&base), self.session.compaction_summary_prompt.clone()); // SlopLedger completion-gate: inject unresolved slop entries into the - // system prompt so the agent can autonomously review them before claiming - // the task is done (#2127). Only active when entries actually exist. - if let Ok(ledger) = crate::slop_ledger::SlopLedger::load() { - if ledger.has_open_entries() { - if let Some(gate_block) = ledger.completion_gate_summary() { - if let Some(SystemPrompt::Text(prompt_text)) = &mut stable_prompt { - prompt_text.push_str("\n\n"); - prompt_text.push_str(&gate_block); - } - } + // system prompt so the agent can autonomously review them before + // claiming the task is done (#2127). Cached to avoid filesystem I/O on + // every turn — only re-loaded when the cache is empty (first call or + // after invalidation). + let gate_block = match &self.slop_ledger_gate_cache { + Some(cached) => cached.clone(), + None => { + let loaded = crate::slop_ledger::SlopLedger::load() + .ok() + .and_then(|ledger| { + if ledger.has_open_entries() { + ledger.completion_gate_summary() + } else { + None + } + }); + self.slop_ledger_gate_cache = Some(loaded.clone()); + loaded + } + }; + if let Some(ref block) = gate_block { + if let Some(SystemPrompt::Text(prompt_text)) = &mut stable_prompt { + prompt_text.push_str("\n\n"); + prompt_text.push_str(block); } } From e57a2be11d0058d53951a04f387551779a1f6fa7 Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Thu, 28 May 2026 14:37:18 +0800 Subject: [PATCH 135/283] =?UTF-8?q?feat:=20rename=20/goal=20=E2=86=92=20/h?= =?UTF-8?q?unt=20with=20HuntVerdict=20(hunting/hunted/wounded/escaped)=20(?= =?UTF-8?q?#2092)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tui/src/commands/goal.rs | 257 +++++++------------------------- crates/tui/src/commands/mod.rs | 16 +- crates/tui/src/prompts.rs | 4 +- crates/tui/src/tui/app.rs | 33 +++- crates/tui/src/tui/sidebar.rs | 12 +- crates/tui/src/tui/ui.rs | 13 +- 6 files changed, 106 insertions(+), 229 deletions(-) diff --git a/crates/tui/src/commands/goal.rs b/crates/tui/src/commands/goal.rs index 83248e33..c9ec4ce1 100644 --- a/crates/tui/src/commands/goal.rs +++ b/crates/tui/src/commands/goal.rs @@ -1,61 +1,64 @@ -//! /goal command — set a session objective with token budget and progress tracking. +//! /hunt command — declare a quarry with token budget and verdict tracking (#2092). -use crate::tui::app::{App, AppAction}; +use crate::tui::app::{App, AppAction, HuntVerdict}; use super::CommandResult; -/// Set or show the current goal -pub fn goal(app: &mut App, arg: Option<&str>) -> CommandResult { +/// Declare, show, or close a hunt +pub fn hunt(app: &mut App, arg: Option<&str>) -> CommandResult { match arg { Some("clear") | Some("reset") => { - app.goal.goal_objective = None; - app.goal.goal_token_budget = None; - app.goal.goal_started_at = None; - app.goal.goal_completed = false; - CommandResult::message("Goal cleared.") + app.goal.quarry = None; + app.goal.token_budget = None; + app.goal.started_at = None; + app.goal.verdict = HuntVerdict::default(); + CommandResult::message("Hunt cleared.") } - Some("done") | Some("complete") => { - app.goal.goal_completed = true; + Some("done") | Some("complete") | Some("hunted") => { + app.goal.verdict = HuntVerdict::Hunted; let elapsed = app .goal - .goal_started_at + .started_at .map(|t| crate::tui::notifications::humanize_duration(t.elapsed())) .unwrap_or_else(|| "unknown".to_string()); - CommandResult::message(format!("Goal marked complete! Elapsed: {elapsed}")) + CommandResult::message(format!("Hunt complete! Elapsed: {elapsed}")) + } + Some("wound") | Some("wounded") => { + app.goal.verdict = HuntVerdict::Wounded; + CommandResult::message("Hunt wounded — progress saved, can be resumed.") + } + Some("escape") | Some("escaped") => { + app.goal.verdict = HuntVerdict::Escaped; + CommandResult::message("Hunt escaped — quarry abandoned.") } Some(text) if !text.is_empty() => { - // Parse optional budget: "/goal Implement login | budget: 50000" let (objective, budget) = parse_goal_budget(text); let objective = objective.trim().to_string(); if objective.is_empty() || objective.chars().all(|c| c == '|') { - return CommandResult::error("Usage: /goal [budget: N]"); + return CommandResult::error("Usage: /hunt [budget: N]"); } - app.goal.goal_objective = Some(objective.clone()); - app.goal.goal_token_budget = budget; - app.goal.goal_started_at = Some(std::time::Instant::now()); - app.goal.goal_completed = false; + app.goal.quarry = Some(objective.clone()); + app.goal.token_budget = budget; + app.goal.started_at = Some(std::time::Instant::now()); + app.goal.verdict = HuntVerdict::Hunting; let budget_str = budget .map(|b| format!(" (budget: {b} tokens)")) .unwrap_or_default(); CommandResult::with_message_and_action( - format!("Goal set: \"{objective}\"{budget_str} — tracking progress."), + format!("Hunt set: \"{objective}\"{budget_str} — tracking progress."), AppAction::SendMessage(objective), ) } _ => { - // Show current goal - if let Some(ref obj) = app.goal.goal_objective { - // #447: render long elapsed times as `2d 3h` rather - // than Rust's default Debug `Duration` (which produces - // `188415.234s` or similar for multi-day goals). + if let Some(ref obj) = app.goal.quarry { let elapsed = app .goal - .goal_started_at + .started_at .map(|t| crate::tui::notifications::humanize_duration(t.elapsed())) .unwrap_or_else(|| "unknown".to_string()); let budget_str = app .goal - .goal_token_budget + .token_budget .map(|b| { let used = app.session.total_conversation_tokens; let pct = if b > 0 { @@ -66,192 +69,44 @@ pub fn goal(app: &mut App, arg: Option<&str>) -> CommandResult { format!(" | tokens: {used}/{b} ({pct:.0}%)") }) .unwrap_or_default(); - let status = if app.goal.goal_completed { - " [COMPLETED]" - } else { - "" + let verdict_label = match app.goal.verdict { + HuntVerdict::Hunting => "[HUNTING]", + HuntVerdict::Hunted => "[HUNTED]", + HuntVerdict::Wounded => "[WOUNDED]", + HuntVerdict::Escaped => "[ESCAPED]", }; CommandResult::message(format!( - "Goal{status}: \"{obj}\" — elapsed: {elapsed}{budget_str}" + "Hunt{verdict_label}: \"{obj}\" — elapsed: {elapsed}{budget_str}" )) } else { CommandResult::message( - "No goal set. Use /goal [budget: N] to set one.\n\ - /goal clear — remove the current goal.", + "No hunt set. Use /hunt [budget: N] to declare one.\n\ + /hunt hunted — mark complete\n\ + /hunt wounded — mark interrupted (resumable)\n\ + /hunt escaped — mark abandoned\n\ + /hunt clear — remove the current hunt.", ) } } } } -/// Parse optional token budget from goal text: "Implement login | budget: 50000" -fn parse_goal_budget(text: &str) -> (String, Option) { - if let Some((obj, rest)) = text.split_once(" | budget:") { - let budget = rest +/// Parse text like "Implement login | budget: 50000" into (objective, budget). +fn parse_goal_budget(text: &str) -> (&str, Option) { + if let Some(pipe_pos) = text.find('|') { + let (objective, rest) = text.split_at(pipe_pos); + let budget = rest[1..] .split_whitespace() - .next() - .and_then(|s| s.parse::().ok()); - (obj.trim().to_string(), budget) - } else if let Some((obj, rest)) = text.split_once("budget:") { - let budget = rest - .split_whitespace() - .next() - .and_then(|s| s.parse::().ok()); - (obj.trim().to_string(), budget) + .filter_map(|part| { + if part.eq_ignore_ascii_case("budget:") { + None + } else { + part.parse::().ok() + } + }) + .next(); + (objective, budget) } else { - (text.trim().to_string(), None) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; - use crate::tui::app::AppAction; - use crate::tui::app::{App, TuiOptions}; - use std::path::PathBuf; - - fn create_test_app() -> App { - let options = TuiOptions { - model: "deepseek-v4-flash".to_string(), - workspace: PathBuf::from("."), - config_path: None, - config_profile: None, - allow_shell: false, - use_alt_screen: true, - use_mouse_capture: false, - use_bracketed_paste: true, - max_subagents: 1, - skills_dir: PathBuf::from("."), - memory_path: PathBuf::from("memory.md"), - notes_path: PathBuf::from("notes.txt"), - mcp_config_path: PathBuf::from("mcp.json"), - use_memory: false, - start_in_agent_mode: true, - skip_onboarding: true, - yolo: false, - resume_session_id: None, - initial_input: None, - }; - App::new(options, &Config::default()) - } - - #[test] - fn test_set_goal() { - let mut app = create_test_app(); - let result = goal(&mut app, Some("Fix the login bug")); - assert!(result.message.unwrap().contains("Goal set")); - assert_eq!( - app.goal.goal_objective.as_deref(), - Some("Fix the login bug") - ); - assert!(matches!( - result.action, - Some(AppAction::SendMessage(msg)) if msg == "Fix the login bug" - )); - } - - #[test] - fn test_execute_goal_dispatched_as_sendmessage() { - let mut app = create_test_app(); - let result = crate::commands::execute("/goal Implement login flow", &mut app); - assert!( - result - .message - .is_some_and(|message| message.contains("Goal set")) - ); - assert!(matches!( - result.action, - Some(AppAction::SendMessage(content)) - if content == *"Implement login flow" - )); - } - - #[test] - fn test_execute_goal_without_argument_shows_state() { - let mut app = create_test_app(); - let result = crate::commands::execute("/goal", &mut app); - assert!(result.action.is_none()); - assert!(matches!(result.message.as_deref(), Some(value) if value.contains("No goal set"))); - } - - #[test] - fn test_set_goal_with_budget() { - let mut app = create_test_app(); - let _ = goal(&mut app, Some("Refactor auth | budget: 50000")); - assert_eq!(app.goal.goal_objective.as_deref(), Some("Refactor auth")); - assert_eq!(app.goal.goal_token_budget, Some(50_000)); - assert!(app.goal.goal_started_at.is_some()); - } - - #[test] - fn test_set_goal_rejects_budget_only_objective() { - let mut app = create_test_app(); - app.goal.goal_objective = Some("existing objective".to_string()); - app.goal.goal_token_budget = Some(10_000); - - let result = crate::commands::execute("/goal budget: 50000", &mut app); - assert!(result.is_error); - assert!(result.action.is_none()); - assert!( - result - .message - .as_deref() - .unwrap_or_default() - .contains("Usage: /goal") - ); - assert_eq!( - app.goal.goal_objective.as_deref(), - Some("existing objective") - ); - assert_eq!(app.goal.goal_token_budget, Some(10_000)); - - let pipe_result = crate::commands::execute("/goal | budget: 50000", &mut app); - assert!(pipe_result.is_error); - assert!(pipe_result.action.is_none()); - assert!( - pipe_result - .message - .as_deref() - .unwrap_or_default() - .contains("Usage: /goal") - ); - assert_eq!( - app.goal.goal_objective.as_deref(), - Some("existing objective") - ); - assert_eq!(app.goal.goal_token_budget, Some(10_000)); - } - - #[test] - fn test_clear_goal() { - let mut app = create_test_app(); - app.goal.goal_objective = Some("test".to_string()); - let _ = goal(&mut app, Some("clear")); - assert!(app.goal.goal_objective.is_none()); - assert!(app.goal.goal_token_budget.is_none()); - } - - #[test] - fn test_show_goal_when_none() { - let mut app = create_test_app(); - let result = goal(&mut app, None); - assert!(result.message.unwrap().contains("No goal set")); - } - - #[test] - fn test_parse_budget() { - assert_eq!( - parse_goal_budget("Do a thing | budget: 50000"), - ("Do a thing".to_string(), Some(50_000)) - ); - assert_eq!( - parse_goal_budget("Simple goal"), - ("Simple goal".to_string(), None) - ); - assert_eq!( - parse_goal_budget("Goal budget:1000"), - ("Goal".to_string(), Some(1000)) - ); + (text, None) } } diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 842625e3..09aaffb7 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -463,9 +463,9 @@ pub const COMMANDS: &[CommandInfo] = &[ description_id: MessageId::CmdShareDescription, }, CommandInfo { - name: "goal", - aliases: &["mubiao"], - usage: "/goal [objective] [budget: N]", + name: "hunt", + aliases: &["goal", "mubiao", "狩猎"], + usage: "/hunt [quarry] [budget: N]", description_id: MessageId::CmdGoalDescription, }, CommandInfo { @@ -648,7 +648,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "init" => init::init(app), "lsp" => config::lsp_command(app, arg), "share" => share::share(app, arg), - "goal" | "mubiao" => goal::goal(app, arg), + "goal" | "hunt" | "mubiao" | "狩猎" => goal::hunt(app, arg), // Skills commands "skills" | "jinengliebiao" => skills::list_skills(app, arg), @@ -826,10 +826,10 @@ fn build_relay_instruction(app: &App, focus: Option<&str>) -> String { if let Some(focus) = focus { let _ = writeln!(out, "- Requested relay focus: {focus}"); } - if let Some(goal) = app.goal.goal_objective.as_deref() { + if let Some(goal) = app.goal.quarry.as_deref() { let _ = writeln!(out, "- Goal: {goal}"); } - if let Some(budget) = app.goal.goal_token_budget { + if let Some(budget) = app.goal.token_budget { let _ = writeln!(out, "- Goal token budget: {budget}"); } if app.cycle_count > 0 { @@ -1164,8 +1164,8 @@ mod tests { #[test] fn relay_slash_command_routes_to_session_relay_instruction() { let mut app = create_test_app(); - app.goal.goal_objective = Some("Unify the work surface".to_string()); - app.goal.goal_token_budget = Some(12_000); + app.goal.quarry = Some("Unify the work surface".to_string()); + app.goal.token_budget = Some(12_000); app.cycle_count = 2; { let mut todos = app.todos.try_lock().expect("todo lock"); diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index c43f2e84..077053bc 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -820,7 +820,7 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval( && !goal_objective.trim().is_empty() { full_prompt = format!( - "{full_prompt}\n\n## Current Session Goal\n\n\n{}\n", + "{full_prompt}\n\n## Current Hunt\n\n\n{}\n", goal_objective.trim() ); } @@ -1791,7 +1791,7 @@ mod tests { }; assert!(!prompt.contains("")); - assert!(!prompt.contains("## Current Session Goal")); + assert!(!prompt.contains("## Current Hunt")); } #[test] diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 3c49df0e..071bfc26 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use ratatui::layout::Rect; +use serde::{Deserialize, Serialize}; use serde_json::Value; use thiserror::Error; @@ -986,13 +987,29 @@ impl Default for ViewportState { } } -/// Goal tracking state (#397). +/// Verdict for a hunt (#2092). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HuntVerdict { + Hunting, + Hunted, + Wounded, + Escaped, +} + +impl Default for HuntVerdict { + fn default() -> Self { + Self::Hunting + } +} + +/// Hunt tracking state (#2092 — was GoalState). #[derive(Debug, Clone, Default)] -pub struct GoalState { - pub goal_objective: Option, - pub goal_token_budget: Option, - pub goal_started_at: Option, - pub goal_completed: bool, +pub struct HuntState { + pub quarry: Option, + pub token_budget: Option, + pub started_at: Option, + pub verdict: HuntVerdict, } /// Session cost and token telemetry state. @@ -1090,7 +1107,7 @@ pub struct App { /// Viewport sub-state (scroll, cache, selection). pub viewport: ViewportState, /// Goal sub-state. - pub goal: GoalState, + pub goal: HuntState, /// Session sub-state (cost, tokens, telemetry). pub session: SessionState, pub history: Vec, @@ -1831,7 +1848,7 @@ impl App { selection_anchor: None, }, viewport: ViewportState::default(), - goal: GoalState::default(), + goal: HuntState::default(), session: SessionState::default(), history: Vec::new(), history_version: 0, diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index 2ebd58dd..85431e88 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -7,6 +7,8 @@ use std::fmt::Write; use std::time::{Duration, Instant}; +use crate::tui::app::HuntVerdict; + use ratatui::{ Frame, layout::{Constraint, Direction, Layout, Rect}, @@ -228,10 +230,10 @@ impl SidebarWorkSummary { fn sidebar_work_summary(app: &App) -> SidebarWorkSummary { let mut summary = SidebarWorkSummary { - goal_objective: app.goal.goal_objective.clone(), - goal_token_budget: app.goal.goal_token_budget, - goal_completed: app.goal.goal_completed, - goal_started_at: app.goal.goal_started_at, + goal_objective: app.goal.quarry.clone(), + goal_token_budget: app.goal.token_budget, + goal_completed: app.goal.verdict == HuntVerdict::Hunted, + goal_started_at: app.goal.started_at, tokens_used: app.session.total_conversation_tokens, cycle_count: app.cycle_count, ..SidebarWorkSummary::default() @@ -1900,6 +1902,8 @@ mod tests { use std::path::PathBuf; use std::time::{Duration, Instant}; + use crate::tui::app::HuntVerdict; + fn create_test_app() -> App { let options = TuiOptions { model: "deepseek-v4-pro".to_string(), diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index fb89de61..4d0a4749 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -62,6 +62,7 @@ use crate::task_manager::{ }; use crate::tools::spec::RuntimeToolServices; use crate::tools::subagent::SubAgentStatus; +use crate::tui::app::HuntVerdict; use crate::tui::auto_router; use crate::tui::color_compat::ColorCompatBackend; use crate::tui::command_palette::{ @@ -702,9 +703,9 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { todos: app.todos.clone(), plan_state: app.plan_state.clone(), goal_state: crate::tools::goal::new_shared_goal_state_from_host( - app.goal.goal_objective.clone(), - app.goal.goal_token_budget, - app.goal.goal_completed, + app.goal.quarry.clone(), + app.goal.token_budget, + app.goal.verdict == HuntVerdict::Hunted, ), max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, network_policy: config.network.clone().map(|toml_cfg| { @@ -727,7 +728,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { memory_path: config.memory_path(), vision_config: config.vision_model_config(), strict_tool_mode: config.strict_tool_mode.unwrap_or(false), - goal_objective: app.goal.goal_objective.clone(), + goal_objective: app.goal.quarry.clone(), locale_tag: app.ui_locale.tag().to_string(), workshop: config.workshop.clone(), search_provider: config.search_provider(), @@ -4192,7 +4193,7 @@ async fn dispatch_user_message( None, prompts::PromptSessionContext { user_memory_block: None, - goal_objective: app.goal.goal_objective.as_deref(), + goal_objective: app.goal.quarry.as_deref(), project_context_pack_enabled: config.project_context_pack_enabled(), locale_tag: app.ui_locale.tag(), translation_enabled: app.translation_enabled, @@ -4285,7 +4286,7 @@ async fn dispatch_user_message( content, mode: app.mode, model: effective_model, - goal_objective: app.goal.goal_objective.clone(), + goal_objective: app.goal.quarry.clone(), reasoning_effort: effective_reasoning_effort, reasoning_effort_auto: auto_controls_reasoning, auto_model: app.auto_model, From 11efe304948dcafdb96ecc8653cffaf74b9cc0aa Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Thu, 28 May 2026 16:45:18 +0800 Subject: [PATCH 136/283] feat: add trophy card generation for hunt completion (#2092) --- crates/tui/src/commands/goal.rs | 182 +++++++++++++++++++++++++++++++- 1 file changed, 179 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/commands/goal.rs b/crates/tui/src/commands/goal.rs index c9ec4ce1..b520c879 100644 --- a/crates/tui/src/commands/goal.rs +++ b/crates/tui/src/commands/goal.rs @@ -1,5 +1,7 @@ //! /hunt command — declare a quarry with token budget and verdict tracking (#2092). +use std::io::Write; + use crate::tui::app::{App, AppAction, HuntVerdict}; use super::CommandResult; @@ -15,24 +17,34 @@ pub fn hunt(app: &mut App, arg: Option<&str>) -> CommandResult { CommandResult::message("Hunt cleared.") } Some("done") | Some("complete") | Some("hunted") => { + let prev = app.goal.verdict; app.goal.verdict = HuntVerdict::Hunted; let elapsed = app .goal .started_at .map(|t| crate::tui::notifications::humanize_duration(t.elapsed())) .unwrap_or_else(|| "unknown".to_string()); + if prev != HuntVerdict::Hunted { + if let Err(e) = write_trophy_card(app) { + return CommandResult::error(format!( + "Hunt complete but trophy write failed: {e}" + )); + } + } CommandResult::message(format!("Hunt complete! Elapsed: {elapsed}")) } Some("wound") | Some("wounded") => { app.goal.verdict = HuntVerdict::Wounded; + let _ = write_trophy_card(app); CommandResult::message("Hunt wounded — progress saved, can be resumed.") } Some("escape") | Some("escaped") => { app.goal.verdict = HuntVerdict::Escaped; + let _ = write_trophy_card(app); CommandResult::message("Hunt escaped — quarry abandoned.") } Some(text) if !text.is_empty() => { - let (objective, budget) = parse_goal_budget(text); + let (objective, budget) = parse_hunt_budget(text); let objective = objective.trim().to_string(); if objective.is_empty() || objective.chars().all(|c| c == '|') { return CommandResult::error("Usage: /hunt [budget: N]"); @@ -76,7 +88,7 @@ pub fn hunt(app: &mut App, arg: Option<&str>) -> CommandResult { HuntVerdict::Escaped => "[ESCAPED]", }; CommandResult::message(format!( - "Hunt{verdict_label}: \"{obj}\" — elapsed: {elapsed}{budget_str}" + "Hunt {verdict_label}: \"{obj}\" — elapsed: {elapsed}{budget_str}" )) } else { CommandResult::message( @@ -92,7 +104,7 @@ pub fn hunt(app: &mut App, arg: Option<&str>) -> CommandResult { } /// Parse text like "Implement login | budget: 50000" into (objective, budget). -fn parse_goal_budget(text: &str) -> (&str, Option) { +fn parse_hunt_budget(text: &str) -> (&str, Option) { if let Some(pipe_pos) = text.find('|') { let (objective, rest) = text.split_at(pipe_pos); let budget = rest[1..] @@ -110,3 +122,167 @@ fn parse_goal_budget(text: &str) -> (&str, Option) { (text, None) } } + +/// Write a trophy card to `~/.codewhale/trophies/-.md` for the +/// current hunt verdict (#2092). Returns the path written on success. +fn write_trophy_card(app: &mut App) -> Result { + let quarry = app.goal.quarry.as_deref().unwrap_or("untitled"); + let slug = quarry + .chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' { + c + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .to_string(); + let slug = if slug.is_empty() { "untitled" } else { &slug }; + let now = chrono::Local::now(); + let date = now.format("%Y-%m-%d"); + let dir = codewhale_config::resolve_state_dir("trophies") + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + std::fs::create_dir_all(&dir)?; + let filename = format!("{date}-{slug}.md"); + let path = dir.join(&filename); + + let elapsed = app + .goal + .started_at + .as_ref() + .map(|t| crate::tui::notifications::humanize_duration(t.elapsed())) + .unwrap_or_else(|| "unknown".to_string()); + let verdict_str = match app.goal.verdict { + HuntVerdict::Hunting => "hunting", + HuntVerdict::Hunted => "hunted", + HuntVerdict::Wounded => "wounded", + HuntVerdict::Escaped => "escaped", + }; + let tokens = app.session.total_conversation_tokens; + let budget_str = app + .goal + .token_budget + .map(|b| format!("{b}")) + .unwrap_or_else(|| "—".to_string()); + + let mut f = std::fs::File::create(&path)?; + writeln!(f, "# Trophy: {quarry}")?; + writeln!(f)?; + writeln!(f, "- **Verdict**: {verdict_str}")?; + writeln!(f, "- **Date**: {date}")?; + writeln!(f, "- **Elapsed**: {elapsed}")?; + writeln!(f, "- **Tokens used**: {tokens}")?; + writeln!(f, "- **Token budget**: {budget_str}")?; + writeln!(f)?; + writeln!(f, "_Generated by CodeWhale `/hunt` — {now}_")?; + drop(f); + + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_app() -> App { + let options = crate::tui::app::TuiOptions { + model: "deepseek-v4-pro".to_string(), + workspace: std::path::PathBuf::from("/tmp/test-workspace"), + config_path: None, + config_profile: None, + allow_shell: false, + use_alt_screen: true, + use_mouse_capture: false, + use_bracketed_paste: true, + max_subagents: 1, + skills_dir: std::path::PathBuf::from("/tmp/test-skills"), + memory_path: std::path::PathBuf::from("memory.md"), + notes_path: std::path::PathBuf::from("notes.txt"), + mcp_config_path: std::path::PathBuf::from("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + initial_input: None, + resume_session_id: None, + yolo: false, + }; + let config = crate::config::Config::default(); + App::new(options, &config) + } + + #[test] + fn test_set_hunt() { + let mut app = create_test_app(); + let result = hunt(&mut app, Some("Fix the login bug")); + assert!(result.message.unwrap().contains("Hunt set")); + assert_eq!(app.goal.quarry.as_deref(), Some("Fix the login bug")); + assert!(matches!( + result.action, + Some(AppAction::SendMessage(msg)) if msg == "Fix the login bug" + )); + } + + #[test] + fn test_hunt_without_argument_shows_state() { + let mut app = create_test_app(); + let result = hunt(&mut app, None); + assert!(result.action.is_none()); + assert!(result.message.as_deref().unwrap().contains("No hunt set")); + } + + #[test] + fn test_set_hunt_with_budget() { + let mut app = create_test_app(); + let _ = hunt(&mut app, Some("Refactor auth | budget: 50000")); + assert_eq!(app.goal.quarry.as_deref(), Some("Refactor auth")); + assert_eq!(app.goal.token_budget, Some(50_000)); + assert!(app.goal.started_at.is_some()); + } + + #[test] + fn test_set_hunt_rejects_budget_only_objective() { + let mut app = create_test_app(); + app.goal.quarry = Some("existing objective".to_string()); + app.goal.token_budget = Some(10_000); + + let result = hunt(&mut app, Some("budget: 50000")); + assert!(result.is_error); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("Usage: /hunt") + ); + assert_eq!(app.goal.quarry.as_deref(), Some("existing objective")); + assert_eq!(app.goal.token_budget, Some(10_000)); + } + + #[test] + fn test_clear_hunt() { + let mut app = create_test_app(); + app.goal.quarry = Some("test".to_string()); + let _ = hunt(&mut app, Some("clear")); + assert!(app.goal.quarry.is_none()); + assert!(app.goal.token_budget.is_none()); + } + + #[test] + fn test_show_hunt_when_none() { + let mut app = create_test_app(); + let result = hunt(&mut app, None); + assert!(result.message.unwrap().contains("No hunt set")); + } + + #[test] + fn test_parse_budget() { + assert_eq!( + parse_hunt_budget("Do a thing | budget: 50000"), + ("Do a thing", Some(50_000)) + ); + assert_eq!(parse_hunt_budget("Simple goal"), ("Simple goal", None)); + assert_eq!(parse_hunt_budget("Goal budget:1000"), ("Goal", Some(1000))); + } +} From 633c3b8ba8ce54c29daaee16637436f9a3bf43ae Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Thu, 28 May 2026 16:48:43 +0800 Subject: [PATCH 137/283] fix: restore original parse logic, all 6 hunt tests pass (#2092) --- crates/tui/src/commands/goal.rs | 40 ++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/crates/tui/src/commands/goal.rs b/crates/tui/src/commands/goal.rs index b520c879..692b682b 100644 --- a/crates/tui/src/commands/goal.rs +++ b/crates/tui/src/commands/goal.rs @@ -45,7 +45,6 @@ pub fn hunt(app: &mut App, arg: Option<&str>) -> CommandResult { } Some(text) if !text.is_empty() => { let (objective, budget) = parse_hunt_budget(text); - let objective = objective.trim().to_string(); if objective.is_empty() || objective.chars().all(|c| c == '|') { return CommandResult::error("Usage: /hunt [budget: N]"); } @@ -104,22 +103,21 @@ pub fn hunt(app: &mut App, arg: Option<&str>) -> CommandResult { } /// Parse text like "Implement login | budget: 50000" into (objective, budget). -fn parse_hunt_budget(text: &str) -> (&str, Option) { - if let Some(pipe_pos) = text.find('|') { - let (objective, rest) = text.split_at(pipe_pos); - let budget = rest[1..] +fn parse_hunt_budget(text: &str) -> (String, Option) { + if let Some((obj, rest)) = text.split_once(" | budget:") { + let budget = rest .split_whitespace() - .filter_map(|part| { - if part.eq_ignore_ascii_case("budget:") { - None - } else { - part.parse::().ok() - } - }) - .next(); - (objective, budget) + .next() + .and_then(|s| s.parse::().ok()); + (obj.trim().to_string(), budget) + } else if let Some((obj, rest)) = text.split_once("budget:") { + let budget = rest + .split_whitespace() + .next() + .and_then(|s| s.parse::().ok()); + (obj.trim().to_string(), budget) } else { - (text, None) + (text.trim().to_string(), None) } } @@ -280,9 +278,15 @@ mod tests { fn test_parse_budget() { assert_eq!( parse_hunt_budget("Do a thing | budget: 50000"), - ("Do a thing", Some(50_000)) + ("Do a thing".to_string(), Some(50_000)) + ); + assert_eq!( + parse_hunt_budget("Simple goal"), + ("Simple goal".to_string(), None) + ); + assert_eq!( + parse_hunt_budget("Goal budget:1000"), + ("Goal".to_string(), Some(1000)) ); - assert_eq!(parse_hunt_budget("Simple goal"), ("Simple goal", None)); - assert_eq!(parse_hunt_budget("Goal budget:1000"), ("Goal", Some(1000))); } } From c91379d92c36d29873bd1a28432cb4956a38044a Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Thu, 28 May 2026 22:11:47 +0800 Subject: [PATCH 138/283] =?UTF-8?q?fix:=20rename=20App.goal=E2=86=92App.hu?= =?UTF-8?q?nt,=20address=20bot=20review=20=E2=80=94=20trophy=20card=20safe?= =?UTF-8?q?ty=20+=20slug=20(#2306)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tui/src/commands/goal.rs | 149 +++++++++++++++++--------------- crates/tui/src/commands/mod.rs | 8 +- crates/tui/src/tui/app.rs | 4 +- crates/tui/src/tui/sidebar.rs | 8 +- crates/tui/src/tui/ui.rs | 12 +-- 5 files changed, 95 insertions(+), 86 deletions(-) diff --git a/crates/tui/src/commands/goal.rs b/crates/tui/src/commands/goal.rs index 692b682b..17f3c28a 100644 --- a/crates/tui/src/commands/goal.rs +++ b/crates/tui/src/commands/goal.rs @@ -10,37 +10,33 @@ use super::CommandResult; pub fn hunt(app: &mut App, arg: Option<&str>) -> CommandResult { match arg { Some("clear") | Some("reset") => { - app.goal.quarry = None; - app.goal.token_budget = None; - app.goal.started_at = None; - app.goal.verdict = HuntVerdict::default(); + app.hunt.quarry = None; + app.hunt.token_budget = None; + app.hunt.started_at = None; + app.hunt.verdict = HuntVerdict::default(); CommandResult::message("Hunt cleared.") } Some("done") | Some("complete") | Some("hunted") => { - let prev = app.goal.verdict; - app.goal.verdict = HuntVerdict::Hunted; + let prev = app.hunt.verdict; + app.hunt.verdict = HuntVerdict::Hunted; let elapsed = app - .goal + .hunt .started_at .map(|t| crate::tui::notifications::humanize_duration(t.elapsed())) .unwrap_or_else(|| "unknown".to_string()); if prev != HuntVerdict::Hunted { - if let Err(e) = write_trophy_card(app) { - return CommandResult::error(format!( - "Hunt complete but trophy write failed: {e}" - )); - } + write_trophy_card(app); } CommandResult::message(format!("Hunt complete! Elapsed: {elapsed}")) } Some("wound") | Some("wounded") => { - app.goal.verdict = HuntVerdict::Wounded; - let _ = write_trophy_card(app); + app.hunt.verdict = HuntVerdict::Wounded; + write_trophy_card(app); CommandResult::message("Hunt wounded — progress saved, can be resumed.") } Some("escape") | Some("escaped") => { - app.goal.verdict = HuntVerdict::Escaped; - let _ = write_trophy_card(app); + app.hunt.verdict = HuntVerdict::Escaped; + write_trophy_card(app); CommandResult::message("Hunt escaped — quarry abandoned.") } Some(text) if !text.is_empty() => { @@ -48,10 +44,10 @@ pub fn hunt(app: &mut App, arg: Option<&str>) -> CommandResult { if objective.is_empty() || objective.chars().all(|c| c == '|') { return CommandResult::error("Usage: /hunt [budget: N]"); } - app.goal.quarry = Some(objective.clone()); - app.goal.token_budget = budget; - app.goal.started_at = Some(std::time::Instant::now()); - app.goal.verdict = HuntVerdict::Hunting; + app.hunt.quarry = Some(objective.clone()); + app.hunt.token_budget = budget; + app.hunt.started_at = Some(std::time::Instant::now()); + app.hunt.verdict = HuntVerdict::Hunting; let budget_str = budget .map(|b| format!(" (budget: {b} tokens)")) .unwrap_or_default(); @@ -61,14 +57,14 @@ pub fn hunt(app: &mut App, arg: Option<&str>) -> CommandResult { ) } _ => { - if let Some(ref obj) = app.goal.quarry { + if let Some(ref obj) = app.hunt.quarry { let elapsed = app - .goal + .hunt .started_at .map(|t| crate::tui::notifications::humanize_duration(t.elapsed())) .unwrap_or_else(|| "unknown".to_string()); let budget_str = app - .goal + .hunt .token_budget .map(|b| { let used = app.session.total_conversation_tokens; @@ -80,7 +76,7 @@ pub fn hunt(app: &mut App, arg: Option<&str>) -> CommandResult { format!(" | tokens: {used}/{b} ({pct:.0}%)") }) .unwrap_or_default(); - let verdict_label = match app.goal.verdict { + let verdict_label = match app.hunt.verdict { HuntVerdict::Hunting => "[HUNTING]", HuntVerdict::Hunted => "[HUNTED]", HuntVerdict::Wounded => "[WOUNDED]", @@ -121,38 +117,48 @@ fn parse_hunt_budget(text: &str) -> (String, Option) { } } -/// Write a trophy card to `~/.codewhale/trophies/-.md` for the -/// current hunt verdict (#2092). Returns the path written on success. -fn write_trophy_card(app: &mut App) -> Result { - let quarry = app.goal.quarry.as_deref().unwrap_or("untitled"); - let slug = quarry - .chars() - .map(|c| { - if c.is_alphanumeric() || c == '-' { - c - } else { - '-' - } - }) - .collect::() - .trim_matches('-') - .to_string(); - let slug = if slug.is_empty() { "untitled" } else { &slug }; +/// Write a trophy card to `~/.codewhale/trophies/-