@hej / sigit / commits / cc64f23

Add progress UI for startup model loading and downloading

Show a progress spinner and download bar while the startup model is loading or downloading. Block requests until the model is ready, and display errors if loading fails. Update the default model to qwen2.5-3b.

paydii committed Apr 28, 2026 at 07:32 UTC cc64f23a717dc8ca4a6b6f8086198edc825c999f
1 file changed +272 -19
src/main.rs
+272 -19
@@ -223,20 +223,20 @@ fn initialize_meta() -> Meta {
223 let active_model_name = startup_selection
224 .as_ref()
225 .map(|selection| selection.display_name.clone())
226 - .unwrap_or_else(|| GgufModelConfig::qwen3_4b().display_name);
226 + .unwrap_or_else(|| GgufModelConfig::qwen25_3b().display_name);
227
228 let active_model_id = startup_selection
229 .as_ref()
230 .and_then(|selection| selection.selected_model.as_ref())
231 .map(|selected| selected.model_id.clone())
232 - .unwrap_or_else(|| GgufModelConfig::qwen3_4b().model_id);
232 + .unwrap_or_else(|| GgufModelConfig::qwen25_3b().model_id);
233
234 let active_model_file = startup_selection
235 .as_ref()
236 .and_then(|selection| selection.selected_model.as_ref())
237 .map(|selected| selected.gguf_file.clone())
238 .unwrap_or_else(|| {
239 - GgufModelConfig::qwen3_4b()
239 + GgufModelConfig::qwen25_3b()
240 .files
241 .first()
242 .cloned()
@@ -271,6 +271,16 @@ struct SiGitAgent {
271 /// cwd from the editor — tool calls run here, not where the process started
272 session_cwd: std::sync::Mutex<Option<PathBuf>>,
273 current_model: std::sync::Mutex<GgufModelConfig>,
274 + /// flipped once the startup model finishes (success or failure)
275 + model_ready: Arc<AtomicBool>,
276 + /// set if the startup load failed
277 + model_load_error: Arc<std::sync::Mutex<Option<String>>>,
278 + /// true when the startup model isn't cached yet
279 + startup_needs_download: bool,
280 + /// for progress UI
281 + startup_model_name: String,
282 + /// for download-progress polling
283 + startup_model_id: String,
284 }
285
286 impl SiGitAgent {
@@ -278,15 +288,190 @@ impl SiGitAgent {
288 engine: Arc<ChatEngine>,
289 notification_tx: mpsc::Sender<SessionNotification>,
290 initial_model: GgufModelConfig,
291 + model_ready: Arc<AtomicBool>,
292 + model_load_error: Arc<std::sync::Mutex<Option<String>>>,
293 + startup_needs_download: bool,
294 ) -> Self {
295 + let startup_model_name = initial_model.display_name.clone();
296 + let startup_model_id = initial_model.model_id.clone();
297 Self {
298 engine,
299 notification_tx,
300 session_cwd: std::sync::Mutex::new(None),
301 current_model: std::sync::Mutex::new(initial_model),
302 + model_ready,
303 + model_load_error,
304 + startup_needs_download,
305 + startup_model_name,
306 + startup_model_id,
307 }
308 }
309
310 + /// block until the startup model is ready, showing progress in the session.
311 + async fn await_model_ready(&self, session_id: &SessionId) -> agent_client_protocol::Result<()> {
312 + if self.model_ready.load(Ordering::Acquire) {
313 + // already done — might be a stored error from earlier
314 + if let Some(err) = self.model_load_error.lock().unwrap().as_ref() {
315 + return Err(agent_client_protocol::Error::new(
316 + -32603,
317 + format!("model load failed: {err}"),
318 + ));
319 + }
320 + return Ok(());
321 + }
322 +
323 + const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
324 +
325 + let tool_call_id = format!("startup-load-{}", uuid::Uuid::new_v4());
326 + let title = if self.startup_needs_download {
327 + format!("Downloading {}", self.startup_model_name)
328 + } else {
329 + format!("Loading {}", self.startup_model_name)
330 + };
331 +
332 + self.send_tool_call_update(
333 + session_id.clone(),
334 + SessionUpdate::ToolCall(
335 + ToolCall::new(tool_call_id.clone(), &title)
336 + .kind(ToolKind::Think)
337 + .status(ToolCallStatus::InProgress)
338 + .content(vec![format!("{}…", title).into()]),
339 + ),
340 + )
341 + .await;
342 +
343 + let expected_bytes = if self.startup_needs_download {
344 + onde::inference::models::SUPPORTED_MODEL_INFO
345 + .iter()
346 + .find(|m| m.id == self.startup_model_id)
347 + .map(|m| m.expected_size_bytes)
348 + .unwrap_or(0)
349 + } else {
350 + 0
351 + };
352 +
353 + let load_start = std::time::Instant::now();
354 + let mut tick: usize = 0;
355 + let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
356 + interval.tick().await;
357 +
358 + loop {
359 + interval.tick().await;
360 + tick += 1;
361 +
362 + if self.model_ready.load(Ordering::Acquire) {
363 + break;
364 + }
365 +
366 + let frame = SPINNER[tick % SPINNER.len()];
367 + let elapsed = load_start.elapsed();
368 + let elapsed_str = if elapsed.as_secs() >= 60 {
369 + format!("{}m {:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
370 + } else {
371 + format!("{}s", elapsed.as_secs())
372 + };
373 +
374 + let (update_title, update_content) =
375 + if self.startup_needs_download && expected_bytes > 0 {
376 + let cache_path = onde::hf_cache::model_cache_path(&self.startup_model_id);
377 + let downloaded = cache_path
378 + .as_ref()
379 + .filter(|p| p.exists())
380 + .map(|p| dir_size_recursive(p))
381 + .unwrap_or(0);
382 + let pct = ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
383 + let bar = progress_bar(pct, 20);
384 + let size_hint = format!(" (~{})", format_size_human(expected_bytes));
385 + (
386 + format!(
387 + "{frame} Downloading {}{size_hint} ({pct}%)",
388 + self.startup_model_name
389 + ),
390 + format!(
391 + "{} — {bar} {pct}% ({} / {})",
392 + self.startup_model_name,
393 + format_size_human(downloaded),
394 + format_size_human(expected_bytes),
395 + ),
396 + )
397 + } else if self.startup_needs_download {
398 + let cache_path = onde::hf_cache::model_cache_path(&self.startup_model_id);
399 + let downloaded = cache_path
400 + .as_ref()
401 + .filter(|p| p.exists())
402 + .map(|p| dir_size_recursive(p))
403 + .unwrap_or(0);
404 + (
405 + format!("{frame} Downloading {}", self.startup_model_name),
406 + format!(
407 + "{} — {} downloaded… ({elapsed_str})",
408 + self.startup_model_name,
409 + format_size_human(downloaded),
410 + ),
411 + )
412 + } else {
413 + (
414 + format!("{frame} Loading {}", self.startup_model_name),
415 + format!(
416 + "{frame} Loading {}… ({elapsed_str})",
417 + self.startup_model_name
418 + ),
419 + )
420 + };
421 +
422 + self.send_tool_call_update(
423 + session_id.clone(),
424 + SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
425 + tool_call_id.clone(),
426 + ToolCallUpdateFields::new()
427 + .title(update_title)
428 + .status(ToolCallStatus::InProgress)
429 + .content(vec![update_content.into()]),
430 + )),
431 + )
432 + .await;
433 + }
434 +
435 + // done — check if it blew up
436 + if let Some(err) = self.model_load_error.lock().unwrap().as_ref() {
437 + self.send_tool_call_update(
438 + session_id.clone(),
439 + SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
440 + tool_call_id,
441 + ToolCallUpdateFields::new()
442 + .title("Model load failed".to_string())
443 + .status(ToolCallStatus::Failed)
444 + .content(vec![format!("error: {err}").into()]),
445 + )),
446 + )
447 + .await;
448 +
449 + return Err(agent_client_protocol::Error::new(
450 + -32603,
451 + format!("model load failed: {err}"),
452 + ));
453 + }
454 +
455 + let done_title = if self.startup_needs_download {
456 + format!("✓ {} downloaded and loaded", self.startup_model_name)
457 + } else {
458 + format!("✓ {} loaded", self.startup_model_name)
459 + };
460 +
461 + self.send_tool_call_update(
462 + session_id.clone(),
463 + SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
464 + tool_call_id,
465 + ToolCallUpdateFields::new()
466 + .title(done_title)
467 + .status(ToolCallStatus::Completed),
468 + )),
469 + )
470 + .await;
471 +
472 + Ok(())
473 + }
474 +
475 async fn send_assistant_message(&self, session_id: SessionId, text: impl Into<String>) {
476 let notification = SessionNotification::new(
477 session_id,
@@ -1007,6 +1192,9 @@ impl Agent for SiGitAgent {
1192 user_text.chars().take(80).collect::<String>()
1193 );
1194
1195 + // wait for the startup model if it's still loading/downloading
1196 + self.await_model_ready(&session_id).await?;
1197 +
1198 // ── tool-calling loop ────────────────────────────────────────────
1199 // send message → execute any tool calls → feed results back
1200 // repeat up to MAX_TOOL_ROUNDS, then force a text reply
@@ -1127,13 +1315,35 @@ impl Agent for SiGitAgent {
1315
1316 let model_id = args.value.0.as_ref();
1317
1318 + // can't switch while the startup model is still loading — the old
1319 + // weights are in GPU memory and the new load gets "does not fit"
1320 + if !self.model_ready.load(Ordering::Acquire) {
1321 + log::info!("set_session_config_option: waiting for startup model to finish loading");
1322 + while !self.model_ready.load(Ordering::Acquire) {
1323 + tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1324 + }
1325 + }
1326 +
1327 + // Zed re-fires the last selection on connect; no-op if it's already loaded
1328 + {
1329 + let current = self.current_model.lock().unwrap();
1330 + if current.model_id == model_id {
1331 + log::info!(
1332 + "set_session_config_option: {} is already the active model, skipping",
1333 + current.display_name
1334 + );
1335 + let config_options = build_model_config_options(&current);
1336 + return Ok(SetSessionConfigOptionResponse::new(config_options));
1337 + }
1338 + }
1339 +
1340 let needs_download = models::build_model_picker_items()
1341 .into_iter()
1342 .find(|item| item.config.model_id == model_id)
1343 .map(|item| item.cache_health == setup::ModelCacheHealth::NotDownloaded)
1344 .unwrap_or(false);
1345
1136 - // shared flag to kill the progress poller when the load finishes
1346 + // tells the progress poller to stop
1347 let stop_flag = Arc::new(AtomicBool::new(false));
1348
1349 let tool_call_id = format!("model-switch-{}", uuid::Uuid::new_v4());
@@ -1165,7 +1375,7 @@ impl Agent for SiGitAgent {
1375 tool_call_id.clone(),
1376 format!("⏬ Downloading {display_name}{size_hint}"),
1377 )
1168 - .kind(ToolKind::Execute)
1378 + .kind(ToolKind::Think)
1379 .status(ToolCallStatus::InProgress)
1380 .content(vec![
1381 format!(
@@ -1263,7 +1473,7 @@ impl Agent for SiGitAgent {
1473 tool_call_id.clone(),
1474 format!("Loading {cached_display_name}"),
1475 )
1266 - .kind(ToolKind::Execute)
1476 + .kind(ToolKind::Think)
1477 .status(ToolCallStatus::InProgress)
1478 .content(vec![format!("Loading {cached_display_name}…").into()]),
1479 ),
@@ -1478,7 +1688,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
1688 let startup_model_name = startup_selection
1689 .as_ref()
1690 .map(|selection| selection.display_name.clone())
1481 - .unwrap_or_else(|| GgufModelConfig::qwen3_4b().display_name);
1691 + .unwrap_or_else(|| GgufModelConfig::qwen25_3b().display_name);
1692
1693 let config = startup_selection
1694 .as_ref()
@@ -1501,7 +1711,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
1711 })
1712 .map(|item| item.config)
1713 })
1504 - .unwrap_or_else(GgufModelConfig::qwen3_4b);
1714 + .unwrap_or_else(GgufModelConfig::qwen25_3b);
1715 let sampling = SamplingConfig {
1716 max_tokens: Some(8192),
1717 ..SamplingConfig::default()
@@ -1556,9 +1766,6 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
1766 async fn run_acp_server() -> anyhow::Result<()> {
1767 log::info!("ACP mode — starting agent server");
1768
1559 - // must load before LocalSet: block_in_place panics inside spawn_local
1560 - log::info!("loading model (this may take a minute on first run)...");
1561 -
1769 let engine = Arc::new(ChatEngine::new());
1770
1771 let startup_selection = setup::startup_model_selection();
@@ -1583,7 +1790,7 @@ async fn run_acp_server() -> anyhow::Result<()> {
1790 })
1791 .map(|item| item.config)
1792 })
1586 - .unwrap_or_else(GgufModelConfig::qwen3_4b);
1793 + .unwrap_or_else(GgufModelConfig::qwen25_3b);
1794
1795 let acp_tool_calling = models::build_model_picker_items()
1796 .iter()
@@ -1599,19 +1806,65 @@ async fn run_acp_server() -> anyhow::Result<()> {
1806 ..SamplingConfig::default()
1807 };
1808
1602 - log::info!("ACP startup model: {}", config.display_name);
1809 + // do we need to download, or is it cached?
1810 + let needs_download = models::build_model_picker_items()
1811 + .iter()
1812 + .find(|item| item.config.model_id == config.model_id)
1813 + .map(|item| item.cache_health != setup::ModelCacheHealth::Complete)
1814 + .unwrap_or(true);
1815 +
1816 + log::info!(
1817 + "ACP startup model: {} ({})",
1818 + config.display_name,
1819 + if needs_download {
1820 + "needs download"
1821 + } else {
1822 + "cached"
1823 + }
1824 + );
1825
1826 let startup_config = config.clone();
1827 +
1828 + // loader thread flips model_ready when done; agent polls it
1829 + let model_ready = Arc::new(AtomicBool::new(false));
1830 + let model_load_error: Arc<std::sync::Mutex<Option<String>>> =
1831 + Arc::new(std::sync::Mutex::new(None));
1832 +
1833 + // real thread + own runtime: block_in_place panics inside spawn_local
1834 + let loader_engine = Arc::clone(&engine);
1835 + let loader_config = config.clone();
1836 + let loader_ready = Arc::clone(&model_ready);
1837 + let loader_error = Arc::clone(&model_load_error);
1838 let acp_system_prompt = system_prompt_for_model(tool_calling).to_string();
1606 - engine
1607 - .load_gguf_model(config, Some(acp_system_prompt), Some(sampling))
1608 - .await
1609 - .map_err(|error| anyhow::anyhow!("model load failed: {error}"))?;
1839
1611 - log::info!("model loaded and ready");
1840 + std::thread::spawn(move || {
1841 + let rt = tokio::runtime::Runtime::new().expect("failed to create loader runtime");
1842 + match rt.block_on(loader_engine.load_gguf_model(
1843 + loader_config,
1844 + Some(acp_system_prompt),
1845 + Some(sampling),
1846 + )) {
1847 + Ok(_) => {
1848 + log::info!("startup model loaded and ready");
1849 + loader_ready.store(true, Ordering::Release);
1850 + }
1851 + Err(error) => {
1852 + log::error!("startup model load failed: {error}");
1853 + *loader_error.lock().unwrap() = Some(error.to_string());
1854 + loader_ready.store(true, Ordering::Release);
1855 + }
1856 + }
1857 + });
1858
1859 let (notification_tx, mut notification_rx) = mpsc::channel::<SessionNotification>(256);
1614 - let agent = SiGitAgent::new(engine, notification_tx, startup_config);
1860 + let agent = SiGitAgent::new(
1861 + engine,
1862 + notification_tx,
1863 + startup_config,
1864 + model_ready,
1865 + model_load_error,
1866 + needs_download,
1867 + );
1868
1869 // AgentSideConnection needs futures-io
1870 let stdin = tokio::io::stdin().compat();