fix(tui): make update hint transient

This commit is contained in:
Hunter Bown
2026-05-27 05:53:33 -05:00
parent 78f8e11952
commit b6edd87135
4 changed files with 145 additions and 38 deletions
-5
View File
@@ -1129,10 +1129,6 @@ pub struct App {
pub status_toasts: VecDeque<StatusToast>,
/// Sticky status toast used for important warnings/errors.
pub sticky_status: Option<StatusToast>,
/// 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<String>,
/// Last status text already promoted from `status_message` into toast state.
pub last_status_message_seen: Option<String>,
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,
+5 -14
View File
@@ -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
+88 -19
View File
@@ -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 = "<turn_meta>";
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<tokio::task::JoinHandle<Option<String>>> = 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(&current)) {
(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, &current)
})
});
@@ -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<String> {
}
}
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()?;
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)> {
+52
View File
@@ -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::<Vec<_>>();
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(&current, "0.8.46").is_none());
}
#[test]
#[cfg(any(unix, windows))]
fn external_url_launcher_does_not_wait_for_browser_process() {