diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index be73ca6b..9a254cdc 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1129,10 +1129,6 @@ pub struct App { pub status_toasts: VecDeque, /// Sticky status toast used for important warnings/errors. pub sticky_status: Option, - /// Version-update hint shown in the footer when a newer release - /// is available. Set by a background GitHub API check after app - /// startup; `None` until the check completes or if up-to-date. - pub version_hint: Option, /// Last status text already promoted from `status_message` into toast state. pub last_status_message_seen: Option, pub model: String, @@ -1871,7 +1867,6 @@ impl App { status_message: None, status_toasts: VecDeque::new(), sticky_status: None, - version_hint: None, last_status_message_seen: None, model, auto_model, diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index dd54f834..9ec3ac83 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -43,21 +43,12 @@ pub(crate) fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { } else { None }; - let toast = quit_prompt - .or_else(|| { - // Version-update hint takes precedence over ephemeral status toasts - // so the user sees it even when status traffic would hide it. - app.version_hint.as_ref().map(|hint| FooterToast { - text: hint.clone(), - color: palette::STATUS_INFO, - }) + let toast = quit_prompt.or_else(|| { + app.active_status_toast().map(|toast| FooterToast { + text: toast.text, + color: status_color(toast.level), }) - .or_else(|| { - app.active_status_toast().map(|toast| FooterToast { - text: toast.text, - color: status_color(toast.level), - }) - }); + }); // Drive every cluster from the user's configured `status_items`. Mode // and Model are always rendered by `FooterProps` itself (their position diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 6ad1f633..20f62652 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -158,6 +158,27 @@ const DEFAULT_TERMINAL_PROBE_TIMEOUT_MS: u64 = 500; const PERIODIC_FULL_REPAINT_EVERY_N: u64 = 50; const TURN_META_PREFIX: &str = ""; const SESSION_TITLE_MAX_CHARS: usize = 32; +const VERSION_HINT_TOAST_TTL_MS: u64 = 12_000; + +const REQUIRED_RELEASE_ASSETS: &[&str] = &[ + "codewhale-artifacts-sha256.txt", + "codewhale-linux-arm64", + "codewhale-linux-arm64.tar.gz", + "codewhale-linux-x64", + "codewhale-linux-x64.tar.gz", + "codewhale-macos-arm64", + "codewhale-macos-arm64.tar.gz", + "codewhale-macos-x64", + "codewhale-macos-x64.tar.gz", + "codewhale-tui-linux-arm64", + "codewhale-tui-linux-x64", + "codewhale-tui-macos-arm64", + "codewhale-tui-macos-x64", + "codewhale-tui-windows-x64.exe", + "codewhale-windows-x64.exe", + "codewhale-windows-x64-portable.zip", + "codewhale-windows-x64.zip", +]; fn is_session_approved_for_tool(app: &App, tool_name: &str, grouping_key: &str) -> bool { app.approval_session_approved.contains(grouping_key) @@ -916,8 +937,8 @@ async fn run_event_loop( .unwrap_or_else(Instant::now); // Fire-and-forget version check — runs once per session in the - // background. On success, `app.version_hint` is set and the footer - // renders the update recommendation on the next frame. + // 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 { @@ -936,22 +957,7 @@ async fn run_event_loop( .await .ok()?; let json: serde_json::Value = resp.json().await.ok()?; - let tag = json["tag_name"].as_str()?; - let latest = tag.trim_start_matches('v'); - // Compare semver so dev builds (e.g. "0.8.46-pre") don't - // trigger false hints. Falls back to string compare on - // unparseable versions. - let newer = match (parse_semver(latest), parse_semver(¤t)) { - (Some(l), Some(c)) => l > c, - _ => latest != current, - }; - if newer { - Some(format!( - "v{latest} available — run `codewhale update` and restart" - )) - } else { - None - } + version_hint_from_release_json(&json, ¤t) }) }); @@ -963,7 +969,11 @@ async fn run_event_loop( done = handle.is_finished(); } if done && let Ok(Some(hint)) = version_check.take().unwrap().await { - app.version_hint = Some(hint); + app.push_status_toast( + hint, + StatusToastLevel::Info, + Some(VERSION_HINT_TOAST_TTL_MS), + ); } if !drain_web_config_events(&mut web_config_session, app, config, &engine_handle).await { @@ -8340,6 +8350,65 @@ fn extract_reasoning_header(text: &str) -> Option { } } +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()?; + let latest = tag.trim_start_matches('v'); + if !is_newer_version(latest, current) { + return None; + } + + Some(format!( + "v{latest} available - run `codewhale update` and restart" + )) +} + +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) + { + return false; + } + + REQUIRED_RELEASE_ASSETS + .iter() + .all(|required| release_has_uploaded_asset(json, required)) +} + +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; + }; + assets.iter().any(|asset| { + asset.get("name").and_then(serde_json::Value::as_str) == Some(required) + && matches!( + asset.get("state").and_then(serde_json::Value::as_str), + None | Some("uploaded") + ) + }) +} + +fn is_newer_version(latest: &str, current: &str) -> bool { + // Compare semver so dev builds (e.g. "0.8.46-pre") don't trigger false + // hints. Falls back to string compare on unparseable versions. + match (parse_semver(latest), parse_semver(current)) { + (Some(l), Some(c)) => l > c, + _ => latest != current, + } +} + /// Parse a `major.minor.patch` version string into a comparable tuple. /// Returns `None` on any parse failure (non-semver, dev suffixes, etc.). fn parse_semver(v: &str) -> Option<(u32, u32, u32)> { diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index b8dfa26e..d70bb848 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -2434,6 +2434,58 @@ fn event_poll_timeout_has_nonzero_floor() { ); } +fn complete_release_json(tag: &str) -> serde_json::Value { + let assets = REQUIRED_RELEASE_ASSETS + .iter() + .map(|name| serde_json::json!({ "name": name, "state": "uploaded" })) + .collect::>(); + serde_json::json!({ + "tag_name": tag, + "draft": false, + "prerelease": false, + "assets": assets, + }) +} + +#[test] +fn version_hint_requires_complete_release_assets() { + let complete = complete_release_json("v0.8.47"); + let hint = version_hint_from_release_json(&complete, "0.8.46").expect("newer complete release"); + assert!(hint.contains("v0.8.47 available")); + + let mut missing_manifest = complete_release_json("v0.8.47"); + missing_manifest["assets"] = serde_json::Value::Array( + missing_manifest["assets"] + .as_array() + .expect("assets") + .iter() + .filter(|asset| { + asset.get("name").and_then(serde_json::Value::as_str) + != Some("codewhale-artifacts-sha256.txt") + }) + .cloned() + .collect(), + ); + assert!( + version_hint_from_release_json(&missing_manifest, "0.8.46").is_none(), + "do not advertise a release before checksums are uploaded" + ); +} + +#[test] +fn version_hint_ignores_draft_prerelease_and_current_versions() { + let mut draft = complete_release_json("v0.8.47"); + draft["draft"] = serde_json::Value::Bool(true); + assert!(version_hint_from_release_json(&draft, "0.8.46").is_none()); + + let mut prerelease = complete_release_json("v0.8.47"); + prerelease["prerelease"] = serde_json::Value::Bool(true); + assert!(version_hint_from_release_json(&prerelease, "0.8.46").is_none()); + + let current = complete_release_json("v0.8.46"); + assert!(version_hint_from_release_json(¤t, "0.8.46").is_none()); +} + #[test] #[cfg(any(unix, windows))] fn external_url_launcher_does_not_wait_for_browser_process() {