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(¤t);
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();