diff --git a/src/app/runtime/actions/mod.rs b/src/app/runtime/actions/mod.rs index 6b9ab75..b7fa35e 100644 --- a/src/app/runtime/actions/mod.rs +++ b/src/app/runtime/actions/mod.rs @@ -300,6 +300,13 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { state.misc.tick_count = state.misc.tick_count.wrapping_add(1); let now_ms = chrono::Utc::now().timestamp_millis(); state.misc.drain_expired_toasts(now_ms); + // Background API connectivity check — runs on a background thread + // every ~1s while disconnected, every ~30s while connected, so the + // status bar reflects real API availability without user input. + let check_interval = if state.misc.api_connected { 600 } else { 20 }; + if state.misc.tick_count % check_interval == 0 { + spawn_api_connectivity_check(state); + } crate::app::review::maybe_run_staleness_sweep(state); if let Some(ref rt) = state.session_runtime { let _ = crate::app::review::process_pending_lessons(&rt.session_dir, &state.memory_dir); @@ -392,6 +399,8 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { if let Some(ref mut rt) = state.session_runtime { rt.push_message(crate::dto::chat::message::ChatMessage::system(message.clone())); } + } else if kind == "connectivity" { + state.misc.api_connected = message == "connected"; } else { state.push_toast(Toast::new(ToastKind::Info, message)); } @@ -1142,6 +1151,54 @@ fn run_oauth_flow(provider: &str) -> anyhow::Result { Ok(format!("Successfully authenticated with {}.", provider)) } +/// Spawn a background thread that checks API reachability via a lightweight HEAD +/// request to `/models`, pushing the result as a `SystemNote` so the +/// next `Tick` handler updates `api_connected`. +/// +/// Flow: resolve the provider's base URL → build a short-lived reqwest client +/// with 3s connect / 5s total timeout → HEAD the `/models` endpoint → push +/// a `connectivity` SystemNote with the result. +/// +/// Why: runs off the event loop so a slow/TIMEOUT network does not block the TUI. +fn spawn_api_connectivity_check(state: &AppStateRest) { + let base_url = state + .app_config + .providers + .get(&state.settings.provider) + .map(|p| p.api_base.clone()) + .unwrap_or_else(|| crate::service::provider::DEFAULT_BASE_URL.to_string()); + let turn_events = state.turn_events.clone(); + + std::thread::spawn(move || { + let url = format!("{}/models", base_url.trim_end_matches('/')); + let connected = match reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .connect_timeout(std::time::Duration::from_secs(3)) + .build() + { + Ok(client) => match client.head(&url).send() { + Ok(resp) => { + let s = resp.status(); + // 401/403 means the server is reachable (just auth is wrong) + s.is_success() || s.as_u16() == 401 || s.as_u16() == 403 + } + Err(_) => false, + }, + Err(_) => false, + }; + if let Ok(mut q) = turn_events.lock() { + q.push_back(TurnEvent::SystemNote { + kind: "connectivity".to_string(), + message: if connected { + "connected".to_string() + } else { + "disconnected".to_string() + }, + }); + } + }); +} + /// Generate `n` pseudo-random bytes from the system clock mixed with a monotonic /// counter, providing sufficient unpredictability for a per-flow OAuth state /// token without a `rand` dependency. diff --git a/src/service/provider.rs b/src/service/provider.rs index a000d5d..c43b59a 100644 --- a/src/service/provider.rs +++ b/src/service/provider.rs @@ -9,7 +9,7 @@ use crate::app::runtime::stream::turn::StreamedTurn; use crate::dto::chat::message::ChatMessage; use crate::dto::provider::request::{ChatRequest, StreamOptions, ToolDef}; -const DEFAULT_BASE_URL: &str = "https://opencode.ai/zen/v1"; +pub(crate) const DEFAULT_BASE_URL: &str = "https://opencode.ai/zen/v1"; const DEFAULT_MODEL: &str = "deepseek-v4-flash-free"; pub const DEFAULT_API_KEY: &str = "sk-5dd268d88adb496b-818beb-6bc7498e"; const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);