diff --git a/crates/tui/src/tools/search.rs b/crates/tui/src/tools/search.rs index b4fc8d1f..98387cb8 100644 --- a/crates/tui/src/tools/search.rs +++ b/crates/tui/src/tools/search.rs @@ -115,17 +115,28 @@ impl ToolSpec for GrepFilesTool { let exclude_patterns: Vec = input.get("exclude").and_then(|v| v.as_array()).map_or_else( || { - // Default exclusions for common non-code directories + // Default exclusions for common non-code directories. + // Bare directory names skip the directory traversal entirely; + // `dir/*` filters files inside if the directory is already + // being walked (belt-and-suspenders — see #2200). vec![ + "node_modules".to_string(), "node_modules/*".to_string(), + ".git".to_string(), ".git/*".to_string(), + "target".to_string(), "target/*".to_string(), "*.min.js".to_string(), "*.min.css".to_string(), + "dist".to_string(), "dist/*".to_string(), + "build".to_string(), "build/*".to_string(), + "__pycache__".to_string(), "__pycache__/*".to_string(), + ".venv".to_string(), ".venv/*".to_string(), + "venv".to_string(), "venv/*".to_string(), ] }, diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 68cc3539..0969c8f8 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1076,6 +1076,10 @@ 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, @@ -1801,6 +1805,7 @@ 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 0269c8de..e8df159c 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -44,6 +44,13 @@ pub(crate) fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { 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, + }) + }).or_else(|| { app.active_status_toast().map(|toast| FooterToast { text: toast.text, color: status_color(toast.level), diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 61cb195c..316bbd34 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -893,7 +893,59 @@ async fn run_event_loop( .checked_sub(Duration::from_secs(60)) .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. + 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()?; + 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 + } + }) + }); + loop { + // Drain the version-check handle once; re-assign None so we + // don't poll it again. + let mut done = false; + if let Some(ref handle) = version_check { + done = handle.is_finished(); + } + if done { + if let Ok(Some(hint)) = version_check.take().unwrap().await { + app.version_hint = Some(hint); + } + } + if !drain_web_config_events(&mut web_config_session, app, config, &engine_handle).await { web_config_session = None; } @@ -7939,5 +7991,15 @@ fn extract_reasoning_header(text: &str) -> Option { } } +/// 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)> { + let mut parts = v.splitn(3, '.'); + let major = parts.next()?.parse::().ok()?; + let minor = parts.next()?.parse::().ok()?; + let patch = parts.next().unwrap_or("0").parse::().ok()?; + Some((major, minor, patch)) +} + #[cfg(test)] mod tests;