diff --git a/config.example.toml b/config.example.toml index 35478529..e92996e4 100644 --- a/config.example.toml +++ b/config.example.toml @@ -71,6 +71,16 @@ reasoning_effort = "max" # Display estimated usage in USD or CNY. Aliases `yuan` and `rmb` normalize to `cny`. cost_currency = "usd" # usd | cny +# ───────────────────────────────────────────────────────────────────────────────── +# Startup update check +# ───────────────────────────────────────────────────────────────────────────────── +# The TUI checks for newer CodeWhale releases in the background at startup. +# Set check_for_updates = false in managed or air-gapped environments. +# update_uri may point at a GitHub-compatible latest-release JSON endpoint. +[update] +check_for_updates = true +# update_uri = "https://internal.mirror.example/codewhale/releases/latest" + # ───────────────────────────────────────────────────────────────────────────────── # Paths # ───────────────────────────────────────────────────────────────────────────────── diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 300e9d99..e7713206 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1287,6 +1287,40 @@ pub struct AutoConfig { pub cost_saving: Option, } +fn default_update_check_for_updates() -> bool { + true +} + +/// Startup update-check configuration (`[update]` table in config.toml). +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct UpdateConfig { + /// When false, skip the TUI startup background update check entirely. + #[serde(default = "default_update_check_for_updates")] + pub check_for_updates: bool, + /// Optional GitHub-compatible latest-release JSON endpoint. + #[serde(default)] + pub update_uri: Option, +} + +impl Default for UpdateConfig { + fn default() -> Self { + Self { + check_for_updates: true, + update_uri: None, + } + } +} + +impl UpdateConfig { + #[must_use] + pub fn update_uri(&self) -> Option<&str> { + self.update_uri + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + } +} + /// Resolved CLI configuration, including defaults and environment overrides. #[derive(Debug, Clone, Default, Deserialize)] pub struct Config { @@ -1395,6 +1429,11 @@ pub struct Config { #[serde(default)] pub auto: Option, + /// Startup update-check behavior. When absent, the TUI keeps the default + /// fire-and-forget latest-release check. + #[serde(default)] + pub update: Option, + /// Post-edit LSP diagnostics injection (#136). When absent, the engine /// applies the defaults documented in [`LspConfigToml`]. #[serde(default)] @@ -2458,6 +2497,12 @@ impl Config { self.snapshots.clone().unwrap_or_default() } + /// Resolve startup update-check settings with defaults applied. + #[must_use] + pub fn update_config(&self) -> UpdateConfig { + self.update.clone().unwrap_or_default() + } + /// Resolve enabled features from defaults and config entries. #[must_use] pub fn features(&self) -> Features { @@ -2696,6 +2741,11 @@ default_text_model = "{DEFAULT_TEXT_MODEL}" # "auto" | "off" | "low" | "medium" | "high" | "max" # Shift+Tab in the TUI cycles between off / high / max. reasoning_effort = "auto" + +# Startup update check +[update] +check_for_updates = true +# update_uri = "https://internal.mirror.example/codewhale/releases/latest" "# ); write_config_file_secure(&config_path, &content) @@ -3717,6 +3767,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config { search: override_cfg.search.or(base.search), memory: override_cfg.memory.or(base.memory), auto: override_cfg.auto.or(base.auto), + update: override_cfg.update.or(base.update), lsp: override_cfg.lsp.or(base.lsp), context: ContextConfig { enabled: override_cfg.context.enabled.or(base.context.enabled), @@ -4712,6 +4763,34 @@ mod tests { ); } + #[test] + fn update_config_defaults_to_enabled_without_uri() { + let config = Config::default(); + assert_eq!(config.update, None); + assert_eq!(config.update_config(), UpdateConfig::default()); + assert!(config.update_config().check_for_updates); + assert_eq!(config.update_config().update_uri(), None); + } + + #[test] + fn update_config_deserializes_disable_and_custom_uri() { + let config: Config = toml::from_str( + r#" + [update] + check_for_updates = false + update_uri = "https://mirror.example/releases/latest" + "#, + ) + .expect("update config"); + + let update = config.update_config(); + assert!(!update.check_for_updates); + assert_eq!( + update.update_uri(), + Some("https://mirror.example/releases/latest") + ); + } + #[test] fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() { let policy: NetworkPolicyToml = toml::from_str( diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 171f168c..779f0dc0 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant}; -use anyhow::Result; +use anyhow::{Context, Result}; // On Windows the push/pop helpers write the escapes directly; crossterm's // PushKeyboardEnhancementFlags / PopKeyboardEnhancementFlags commands are // never referenced, so the imports are gated to avoid -D warnings failures. @@ -43,7 +43,7 @@ use crate::commands; use crate::compaction::estimate_input_tokens_conservative; use crate::config::{ ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL, ProviderConfig, ProvidersConfig, StatusItem, - save_provider_auth_mode_for, + UpdateConfig, save_provider_auth_mode_for, }; use crate::config_ui::{self, ConfigUiMode, WebConfigSession, WebConfigSessionEvent}; use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine}; @@ -999,27 +999,8 @@ async fn run_event_loop( // Fire-and-forget version check — runs once per session in the // background. On success, a short status toast advertises the update // without replacing the user's configured footer/status-line chips. - let mut version_check: Option>> = Some({ - let current = env!("CARGO_PKG_VERSION").to_string(); - tokio::spawn(async move { - let client = match reqwest::Client::builder() - .user_agent("codewhale-version-check") - .timeout(std::time::Duration::from_secs(5)) - .build() - { - Ok(c) => c, - Err(_) => return None, - }; - let resp = client - .get("https://api.github.com/repos/Hmbown/CodeWhale/releases/latest") - .header("Accept", "application/vnd.github+json") - .send() - .await - .ok()?; - let json: serde_json::Value = resp.json().await.ok()?; - version_hint_from_release_json(&json, ¤t) - }) - }); + let mut version_check: Option>> = + spawn_startup_version_check(config.update_config()); // Fire a one-shot initial balance fetch for DeepSeek providers // so the footer chip shows balance on the first frame without @@ -8668,12 +8649,119 @@ fn extract_reasoning_header(text: &str) -> Option { } } +#[derive(Debug, Clone, PartialEq, Eq)] +enum StartupVersionCheckSource { + Disabled, + ConfiguredUrl(String), + ReleaseResolver, +} + +fn startup_version_check_source(config: &UpdateConfig) -> StartupVersionCheckSource { + if !config.check_for_updates { + return StartupVersionCheckSource::Disabled; + } + if let Some(update_uri) = config.update_uri() { + return StartupVersionCheckSource::ConfiguredUrl(update_uri.to_string()); + } + StartupVersionCheckSource::ReleaseResolver +} + +fn spawn_startup_version_check( + config: UpdateConfig, +) -> Option>> { + let source = startup_version_check_source(&config); + if source == StartupVersionCheckSource::Disabled { + return None; + } + + let current = env!("CARGO_PKG_VERSION").to_string(); + Some(tokio::spawn(async move { + version_hint_from_startup_source(source, ¤t).await + })) +} + +async fn version_hint_from_startup_source( + source: StartupVersionCheckSource, + current: &str, +) -> Option { + match source { + StartupVersionCheckSource::Disabled => None, + StartupVersionCheckSource::ConfiguredUrl(url) => { + match version_hint_from_configured_update_uri(&url, current).await { + Ok(hint) => hint, + Err(_) => version_hint_from_release_mirror_env(current).await, + } + } + StartupVersionCheckSource::ReleaseResolver => { + if release_mirror_env_configured() { + return version_hint_from_release_mirror_env(current).await; + } + + let body = codewhale_release::fetch_release_json_async( + codewhale_release::LATEST_RELEASE_URL, + "latest release", + ) + .await + .ok()?; + let json: serde_json::Value = serde_json::from_str(&body).ok()?; + version_hint_from_release_json(&json, current) + } + } +} + +async fn version_hint_from_release_mirror_env(current: &str) -> Option { + if !release_mirror_env_configured() { + return None; + } + let tag = + codewhale_release::latest_release_tag_async(codewhale_release::ReleaseChannel::Stable) + .await + .ok()?; + version_hint_from_latest_tag(&tag, current) +} + +fn release_mirror_env_configured() -> bool { + let version = codewhale_release::update_version_from_env() + .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string()); + codewhale_release::release_base_url_from_env(&version).is_some() +} + +async fn version_hint_from_configured_update_uri( + update_uri: &str, + current: &str, +) -> Result> { + let body = codewhale_release::fetch_release_json_async(update_uri, "configured latest release") + .await?; + let json: serde_json::Value = serde_json::from_str(&body).with_context(|| { + format!("failed to parse release JSON from configured URI {update_uri}") + })?; + Ok(version_hint_from_custom_release_json(&json, current)) +} + fn version_hint_from_release_json(json: &serde_json::Value, current: &str) -> Option { if !release_has_required_assets(json) { return None; } let tag = json["tag_name"].as_str()?; + version_hint_from_latest_tag(tag, current) +} + +fn version_hint_from_custom_release_json( + json: &serde_json::Value, + current: &str, +) -> Option { + if !release_is_publishable(json) { + return None; + } + if json.get("assets").is_some() && !release_has_required_assets(json) { + return None; + } + let tag = json["tag_name"].as_str()?; + version_hint_from_latest_tag(tag, current) +} + +fn version_hint_from_latest_tag(tag: &str, current: &str) -> Option { let latest = tag.trim_start_matches('v'); if !is_newer_version(latest, current) { return None; @@ -8685,18 +8773,7 @@ fn version_hint_from_release_json(json: &serde_json::Value, current: &str) -> Op } fn release_has_required_assets(json: &serde_json::Value) -> bool { - if json - .get("draft") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) - { - return false; - } - if json - .get("prerelease") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) - { + if !release_is_publishable(json) { return false; } @@ -8705,6 +8782,17 @@ fn release_has_required_assets(json: &serde_json::Value) -> bool { .all(|required| release_has_uploaded_asset(json, required)) } +fn release_is_publishable(json: &serde_json::Value) -> bool { + !json + .get("draft") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + && !json + .get("prerelease") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) +} + fn release_has_uploaded_asset(json: &serde_json::Value, required: &str) -> bool { let Some(assets) = json.get("assets").and_then(serde_json::Value::as_array) else { return false; diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 51240f01..82e50854 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -2868,6 +2868,45 @@ fn version_hint_ignores_draft_prerelease_and_current_versions() { assert!(version_hint_from_release_json(¤t, "0.8.46").is_none()); } +#[test] +fn startup_version_check_source_respects_update_config() { + assert_eq!( + startup_version_check_source(&UpdateConfig { + check_for_updates: false, + update_uri: Some("https://mirror.example/releases/latest".to_string()), + }), + StartupVersionCheckSource::Disabled + ); + + assert_eq!( + startup_version_check_source(&UpdateConfig { + check_for_updates: true, + update_uri: Some(" https://mirror.example/releases/latest ".to_string()), + }), + StartupVersionCheckSource::ConfiguredUrl( + "https://mirror.example/releases/latest".to_string() + ) + ); + + assert_eq!( + startup_version_check_source(&UpdateConfig::default()), + StartupVersionCheckSource::ReleaseResolver + ); +} + +#[test] +fn custom_update_uri_accepts_tag_only_release_json() { + let json = serde_json::json!({ + "tag_name": "v0.8.47", + "draft": false, + "prerelease": false, + }); + + let hint = version_hint_from_custom_release_json(&json, "0.8.46") + .expect("tag-only custom metadata should be enough for mirrors"); + assert!(hint.contains("v0.8.47 available")); +} + #[test] #[cfg(any(unix, windows))] fn external_url_launcher_does_not_wait_for_browser_process() { diff --git a/crates/tui/tests/qa_pty.rs b/crates/tui/tests/qa_pty.rs index d5d4b5f5..8e9a8644 100644 --- a/crates/tui/tests/qa_pty.rs +++ b/crates/tui/tests/qa_pty.rs @@ -13,6 +13,7 @@ #[path = "support/qa_harness/mod.rs"] mod qa_harness; +use std::sync::{Mutex, MutexGuard}; use std::time::Duration; use qa_harness::harness::{Harness, make_sealed_workspace}; @@ -20,6 +21,13 @@ use qa_harness::keys; const BOOT_TIMEOUT: Duration = Duration::from_secs(15); const KEY_TIMEOUT: Duration = Duration::from_secs(5); +static QA_PTY_TEST_LOCK: Mutex<()> = Mutex::new(()); + +fn qa_pty_test_lock() -> MutexGuard<'static, ()> { + QA_PTY_TEST_LOCK + .lock() + .unwrap_or_else(|poison| poison.into_inner()) +} fn boot_minimal() -> anyhow::Result<(qa_harness::harness::SealedWorkspace, Harness)> { let ws = make_sealed_workspace()?; @@ -95,6 +103,7 @@ fn assert_viewport_starts_at_top(frame: &qa_harness::Frame) { /// broken before we worry about any scenario. #[test] fn smoke_boot_paints_composer() -> anyhow::Result<()> { + let _guard = qa_pty_test_lock(); let (_ws, mut h) = boot_minimal()?; // The composer panel border is labelled "Composer" — wait for it. @@ -115,6 +124,7 @@ fn smoke_boot_paints_composer() -> anyhow::Result<()> { /// origin/scroll-region state must not leave blank rows above the TUI. #[test] fn viewport_origin_stays_row_zero_after_failed_turn() -> anyhow::Result<()> { + let _guard = qa_pty_test_lock(); let (_ws, mut h) = boot_minimal_without_retry()?; h.wait_for_text("Composer", BOOT_TIMEOUT)?; assert_viewport_starts_at_top(h.frame()); @@ -142,6 +152,7 @@ fn viewport_origin_stays_row_zero_after_failed_turn() -> anyhow::Result<()> { /// we lean on it for real scenarios. #[test] fn smoke_keystroke_reaches_composer() -> anyhow::Result<()> { + let _guard = qa_pty_test_lock(); let (_ws, mut h) = boot_minimal()?; h.wait_for_text("Composer", BOOT_TIMEOUT)?; @@ -157,6 +168,7 @@ fn smoke_keystroke_reaches_composer() -> anyhow::Result<()> { /// skills directory. #[test] fn skills_menu_shows_local_and_global_skills() -> anyhow::Result<()> { + let _guard = qa_pty_test_lock(); let ws = make_sealed_workspace()?; write_skill(ws.user_skills_dir(), "global-alpha", "Global alpha skill")?; write_skill( @@ -182,6 +194,7 @@ fn skills_menu_shows_local_and_global_skills() -> anyhow::Result<()> { h.wait_for_text("Composer", BOOT_TIMEOUT)?; h.send(keys::key::text("/skills"))?; + h.wait_for_text("/skills", KEY_TIMEOUT)?; h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(2))?; h.send(keys::key::enter())?; h.wait_for_text("Available skills", KEY_TIMEOUT)?; @@ -210,6 +223,7 @@ fn skills_menu_shows_local_and_global_skills() -> anyhow::Result<()> { /// holding the text, not start a turn. #[test] fn paste_bracketed_with_trailing_newline_does_not_autosubmit() -> anyhow::Result<()> { + let _guard = qa_pty_test_lock(); let (_ws, mut h) = boot_minimal()?; h.wait_for_text("Composer", BOOT_TIMEOUT)?; @@ -250,6 +264,7 @@ fn paste_bracketed_with_trailing_newline_does_not_autosubmit() -> anyhow::Result /// This is the Windows / PowerShell repro from #1073. #[test] fn paste_unbracketed_with_trailing_newline_does_not_autosubmit() -> anyhow::Result<()> { + let _guard = qa_pty_test_lock(); let (_ws, mut h) = boot_minimal()?; h.wait_for_text("Composer", BOOT_TIMEOUT)?; // Let the boot fully settle so input handling is wired up. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e6aab6d7..9344b830 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -168,6 +168,37 @@ distinct set of commands (`auth`, `config`, `model`, `thread`, `sandbox`, `app-server`, `mcp-server`, `completion`) and forwards plain prompts to `codewhale-tui`. +### Startup Update Checks + +By default, the TUI starts a background check for the latest stable CodeWhale +release and shows a short toast only when a newer release is available and the +official release assets are complete. + +Disable the startup check entirely for air-gapped, corporate-proxy, or managed +desktop environments: + +```toml +[update] +check_for_updates = false +``` + +To redirect the startup check, set `update_uri` to an internal endpoint that +returns GitHub-compatible latest-release JSON. Minimal mirror metadata with a +`tag_name` field is accepted; if `assets` are present, CodeWhale requires the +same uploaded asset set as the official release before showing the toast. + +```toml +[update] +check_for_updates = true +update_uri = "https://internal.mirror.example/codewhale/releases/latest" +``` + +When `update_uri` is not set, startup checks honor release mirror environment +variables such as `CODEWHALE_RELEASE_BASE_URL` before falling back to the +official GitHub API endpoint. If a configured `update_uri` cannot be fetched or +parsed and a release mirror env var is set, the TUI falls back to that mirror +instead of failing startup. + ## Profiles You can define multiple profiles in the same file: @@ -311,6 +342,9 @@ Remaining variables: - `CODEWHALE_HOME` (override the base data directory; defaults to `~/.codewhale`). If you previously exported `DEEPSEEK_HOME`, rename it to `CODEWHALE_HOME`; the old env var is not used for new CodeWhale state paths. +- `CODEWHALE_RELEASE_BASE_URL` (release asset mirror used by `codewhale update` + and by TUI startup update checks when `[update].update_uri` is not set, or as + a fallback when that configured URI cannot be fetched) - `DEEPSEEK_AUTOMATIONS_DIR` (override the automations storage directory; uses `~/.codewhale/automations` when that directory exists, otherwise the legacy `~/.deepseek/automations` path)