fix(tui): make startup update checks configurable

This commit is contained in:
Hunter B
2026-05-31 16:56:16 -07:00
parent 78d6cb48d3
commit a9a4213d39
6 changed files with 300 additions and 35 deletions
+10
View File
@@ -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
# ─────────────────────────────────────────────────────────────────────────────────
+79
View File
@@ -1287,6 +1287,40 @@ pub struct AutoConfig {
pub cost_saving: Option<bool>,
}
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<String>,
}
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<AutoConfig>,
/// Startup update-check behavior. When absent, the TUI keeps the default
/// fire-and-forget latest-release check.
#[serde(default)]
pub update: Option<UpdateConfig>,
/// 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(
+123 -35
View File
@@ -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<tokio::task::JoinHandle<Option<String>>> = 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, &current)
})
});
let mut version_check: Option<tokio::task::JoinHandle<Option<String>>> =
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<String> {
}
}
#[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<tokio::task::JoinHandle<Option<String>>> {
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, &current).await
}))
}
async fn version_hint_from_startup_source(
source: StartupVersionCheckSource,
current: &str,
) -> Option<String> {
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<String> {
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<Option<String>> {
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<String> {
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<String> {
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<String> {
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;
+39
View File
@@ -2868,6 +2868,45 @@ fn version_hint_ignores_draft_prerelease_and_current_versions() {
assert!(version_hint_from_release_json(&current, "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() {
+15
View File
@@ -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.
+34
View File
@@ -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)