fix(tui): make update hint transient
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(¤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<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)> {
|
||||
|
||||
@@ -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(¤t, "0.8.46").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(any(unix, windows))]
|
||||
fn external_url_launcher_does_not_wait_for_browser_process() {
|
||||
|
||||
Reference in New Issue
Block a user