Show model download and loading progress in Zed agent
Add download and loading progress notifications when switching models in the Zed agent. Show a progress bar during downloads and a spinner during model loading. Progress pollers are stopped once the model is loaded or on error.
paydii committed
Apr 26, 2026 at 08:29 UTC
f543385723ccfc8b04a2d4f7f349aea5590f81ca
2 files changed
+254
-26
src/chat.rs
+35
-25
@@ -162,6 +162,9 @@ struct App {
162
blink_counter: u8,
163
/// True while a model switch is in progress.
164
switching_model: bool,
165
+ /// Tool-calling flag for the model currently being loaded in the background.
166
+ /// Applied to `app.tool_calling` when `ModelLoadUpdate::Loaded` arrives.
167
+ pending_tool_calling: Option<bool>,
168
169
// ── Loading-phase state ───────────────────────────────────────────────────
170
/// True while the model is still loading; switches to false on completion.
@@ -226,6 +229,7 @@ impl App {
229
blink_on: true,
230
blink_counter: 0,
231
switching_model: false,
232
+ pending_tool_calling: None,
233
is_loading: true,
234
load_tick: 0,
235
load_error: None,
@@ -1107,7 +1111,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<String> {
1111
async fn exec_slash<B: ratatui::backend::Backend>(
1112
app: &mut App,
1113
cmd: SlashCommand,
1110
- engine: &ChatEngine,
1114
+ engine: Arc<ChatEngine>,
1115
terminal: &mut ratatui::Terminal<B>,
1116
) {
1117
match cmd {
@@ -1130,7 +1134,7 @@ async fn exec_slash<B: ratatui::backend::Backend>(
1134
)));
1135
}
1136
SlashCommand::Status => {
1133
- let info = engine.info().await;
1137
+ let info = engine.as_ref().info().await;
1138
let model = info.model_name.as_deref().unwrap_or("(none)");
1139
let mem = info.approx_memory.as_deref().unwrap_or("unknown");
1140
app.messages.push(ChatMessage::system(format!(
@@ -1140,7 +1144,7 @@ async fn exec_slash<B: ratatui::backend::Backend>(
1144
}
1145
SlashCommand::Models(selection) => match selection {
1146
None => {
1143
- app.open_model_picker(engine);
1147
+ app.open_model_picker(&engine);
1148
}
1149
Some(n) => {
1150
let idx = n.saturating_sub(1);
@@ -1182,28 +1186,30 @@ async fn exec_slash<B: ratatui::backend::Backend>(
1186
..SamplingConfig::default()
1187
};
1188
1185
- // load_gguf_model unloads any existing model internally before
1186
- // loading the new one. Calling unload_model() explicitly first
1187
- // would create a window where no model is loaded — if a message
1188
- // arrived in that gap it would fail with NoModelLoaded.
1189
+ // Spawn onto a background task so the event loop keeps
1190
+ // running (and the spinner keeps animating) during the
1191
+ // download + load — which can take several minutes for
1192
+ // a large model fetched from HuggingFace for the first time.
1193
let system_prompt = crate::system_prompt_for_model(model.tool_calling);
1190
- let update = match engine
1191
- .load_gguf_model(
1192
- model.config.clone(),
1193
- Some(system_prompt.to_string()),
1194
- Some(sampling),
1195
- )
1196
- .await
1197
- {
1198
- Ok(_) => {
1199
- engine.clear_history().await;
1200
- app.tool_calling = model.tool_calling;
1201
- ModelLoadUpdate::Loaded(model.display_name.clone())
1202
- }
1203
- Err(err) => ModelLoadUpdate::Error(err.to_string()),
1204
- };
1205
-
1206
- let _ = tx.send(update).await;
1194
+ let engine_handle = Arc::clone(&engine);
1195
+ let tool_calling = model.tool_calling;
1196
+ tokio::spawn(async move {
1197
+ let update = match engine_handle
1198
+ .load_gguf_model(
1199
+ model.config.clone(),
1200
+ Some(system_prompt.to_string()),
1201
+ Some(sampling),
1202
+ )
1203
+ .await
1204
+ {
1205
+ Ok(_) => ModelLoadUpdate::Loaded(model.display_name.clone()),
1206
+ Err(err) => ModelLoadUpdate::Error(err.to_string()),
1207
+ };
1208
+ let _ = tx.send(update).await;
1209
+ });
1210
+ // tool_calling is applied when ModelLoadUpdate::Loaded
1211
+ // arrives in the event loop (see model_load_rx handler).
1212
+ app.pending_tool_calling = Some(tool_calling);
1213
}
1214
}
1215
}
@@ -1377,6 +1383,10 @@ async fn event_loop<B: ratatui::backend::Backend>(
1383
if let Some(rx) = app.model_load_rx.as_mut() {
1384
match rx.try_recv() {
1385
Ok(ModelLoadUpdate::Loaded(model_name)) => {
1386
+ engine.clear_history().await;
1387
+ if let Some(tc) = app.pending_tool_calling.take() {
1388
+ app.tool_calling = tc;
1389
+ }
1390
app.switching_model = false;
1391
app.model_load_rx = None;
1392
app.current_model_name = model_name.clone();
@@ -1548,7 +1558,7 @@ async fn event_loop<B: ratatui::backend::Backend>(
1558
1559
if let Some(text) = handle_key(&mut app, key) {
1560
if let Some(cmd) = parse_slash(&text) {
1551
- exec_slash(&mut app, cmd, &engine, terminal).await;
1561
+ exec_slash(&mut app, cmd, Arc::clone(&engine), terminal).await;
1562
continue;
1563
}
1564
src/main.rs
+219
-1
@@ -66,6 +66,7 @@ use agent_client_protocol::{
66
use futures::future::LocalBoxFuture;
67
use onde::inference::{ChatEngine, GgufModelConfig, ToolDefinition, ToolResult};
68
use std::path::PathBuf;
69
+use std::sync::atomic::{AtomicBool, Ordering};
70
use tokio::sync::mpsc;
71
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
72
use tracing_subscriber::{EnvFilter, fmt as tracing_fmt};
@@ -1163,7 +1164,179 @@ impl Agent for SiGitAgent {
1164
}
1165
1166
let model_id = args.value.0.as_ref();
1166
- let _new_config = self.switch_model_by_id(model_id).await?;
1167
+
1168
+ // Check if this model needs to be downloaded first so we can show
1169
+ // a progress indicator in Zed while the download + load is happening.
1170
+ let needs_download = models::build_model_picker_items()
1171
+ .into_iter()
1172
+ .find(|item| item.config.model_id == model_id)
1173
+ .map(|item| item.cache_health == setup::ModelCacheHealth::NotDownloaded)
1174
+ .unwrap_or(false);
1175
+
1176
+ // Spawn a progress-poller task that sends periodic download status
1177
+ // messages to Zed via the notification channel. A shared flag lets
1178
+ // us stop the poller once the load finishes.
1179
+ let stop_flag = Arc::new(AtomicBool::new(false));
1180
+
1181
+ if needs_download {
1182
+ // Send the initial "downloading" banner immediately.
1183
+ let model_id_owned = model_id.to_string();
1184
+ let expected_bytes = onde::inference::models::SUPPORTED_MODEL_INFO
1185
+ .iter()
1186
+ .find(|m| m.id == model_id_owned)
1187
+ .map(|m| m.expected_size_bytes)
1188
+ .unwrap_or(0);
1189
+
1190
+ let display_name = models::build_model_picker_items()
1191
+ .into_iter()
1192
+ .find(|item| item.config.model_id == model_id_owned)
1193
+ .map(|item| item.display_name.clone())
1194
+ .unwrap_or_else(|| model_id_owned.clone());
1195
+
1196
+ let size_hint = if expected_bytes > 0 {
1197
+ format!(" (~{})", format_size_human(expected_bytes))
1198
+ } else {
1199
+ String::new()
1200
+ };
1201
+
1202
+ self.send_assistant_message(
1203
+ args.session_id.clone(),
1204
+ format!("⏬ Downloading {display_name}{size_hint}… this may take a few minutes."),
1205
+ )
1206
+ .await;
1207
+
1208
+ // Poller: every 4 seconds report bytes-on-disk / expected.
1209
+ let poller_tx = self.notification_tx.clone();
1210
+ let poller_session = args.session_id.clone();
1211
+ let poller_model_id = model_id_owned.clone();
1212
+ let poller_stop = Arc::clone(&stop_flag);
1213
+
1214
+ tokio::spawn(async move {
1215
+ let cache_path = onde::hf_cache::model_cache_path(&poller_model_id);
1216
+ let mut interval = tokio::time::interval(std::time::Duration::from_secs(4));
1217
+ interval.tick().await; // consume the immediate first tick
1218
+
1219
+ while !poller_stop.load(Ordering::Relaxed) {
1220
+ interval.tick().await;
1221
+
1222
+ if poller_stop.load(Ordering::Relaxed) {
1223
+ break;
1224
+ }
1225
+
1226
+ let downloaded = cache_path
1227
+ .as_ref()
1228
+ .filter(|p| p.exists())
1229
+ .map(|p| dir_size_recursive(p))
1230
+ .unwrap_or(0);
1231
+
1232
+ let msg = if expected_bytes > 0 {
1233
+ let pct =
1234
+ ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8;
1235
+ let bar = progress_bar(pct, 20);
1236
+ format!(
1237
+ "⏬ {display_name} — {bar} {pct}% ({} / {})",
1238
+ format_size_human(downloaded),
1239
+ format_size_human(expected_bytes),
1240
+ )
1241
+ } else {
1242
+ format!(
1243
+ "⏬ {display_name} — {} downloaded…",
1244
+ format_size_human(downloaded)
1245
+ )
1246
+ };
1247
+
1248
+ let notification = SessionNotification::new(
1249
+ poller_session.clone(),
1250
+ SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(
1251
+ msg,
1252
+ ))),
1253
+ );
1254
+ if poller_tx.send(notification).await.is_err() {
1255
+ break;
1256
+ }
1257
+ }
1258
+ });
1259
+ }
1260
+
1261
+ // For already-cached models, send a "loading" message and a spinner
1262
+ // so the user sees activity while mistralrs loads the weights (~10-30 s).
1263
+ if !needs_download {
1264
+ let cached_display_name = models::build_model_picker_items()
1265
+ .into_iter()
1266
+ .find(|item| item.config.model_id == model_id)
1267
+ .map(|item| item.display_name.clone())
1268
+ .unwrap_or_else(|| model_id.to_string());
1269
+
1270
+ self.send_assistant_message(
1271
+ args.session_id.clone(),
1272
+ format!("⏳ Loading {cached_display_name}…"),
1273
+ )
1274
+ .await;
1275
+
1276
+ // Spinner poller: send an elapsed-time update every 5 seconds so
1277
+ // the user can tell siGit is still working.
1278
+ let spinner_tx = self.notification_tx.clone();
1279
+ let spinner_session = args.session_id.clone();
1280
+ let spinner_name = cached_display_name.clone();
1281
+ let spinner_stop = Arc::clone(&stop_flag);
1282
+ let load_start = std::time::Instant::now();
1283
+
1284
+ tokio::spawn(async move {
1285
+ const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1286
+ let mut tick: usize = 0;
1287
+ let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
1288
+ interval.tick().await; // consume the immediate first tick
1289
+
1290
+ while !spinner_stop.load(Ordering::Relaxed) {
1291
+ interval.tick().await;
1292
+
1293
+ if spinner_stop.load(Ordering::Relaxed) {
1294
+ break;
1295
+ }
1296
+
1297
+ let elapsed = load_start.elapsed();
1298
+ let elapsed_str = if elapsed.as_secs() >= 60 {
1299
+ format!("{}m {:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
1300
+ } else {
1301
+ format!("{}s", elapsed.as_secs())
1302
+ };
1303
+ let frame = SPINNER[tick % SPINNER.len()];
1304
+ tick += 1;
1305
+
1306
+ let msg = format!("{frame} Loading {spinner_name}… ({elapsed_str})");
1307
+ let notification = SessionNotification::new(
1308
+ spinner_session.clone(),
1309
+ SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(
1310
+ msg,
1311
+ ))),
1312
+ );
1313
+ if spinner_tx.send(notification).await.is_err() {
1314
+ break;
1315
+ }
1316
+ }
1317
+ });
1318
+ }
1319
+
1320
+ let switch_result = self.switch_model_by_id(model_id).await;
1321
+
1322
+ // Stop the progress / spinner poller regardless of success/failure.
1323
+ stop_flag.store(true, Ordering::Relaxed);
1324
+
1325
+ let new_config = switch_result?;
1326
+
1327
+ if needs_download {
1328
+ self.send_assistant_message(
1329
+ args.session_id.clone(),
1330
+ format!("✓ {} downloaded and loaded.", new_config.display_name),
1331
+ )
1332
+ .await;
1333
+ } else {
1334
+ self.send_assistant_message(
1335
+ args.session_id.clone(),
1336
+ format!("✓ Switched to {}.", new_config.display_name),
1337
+ )
1338
+ .await;
1339
+ }
1340
1341
let config_options = {
1342
let guard = self.current_model.lock().unwrap();
@@ -1175,6 +1348,51 @@ impl Agent for SiGitAgent {
1348
}
1349
}
1350
1351
+// ── Download progress helpers ─────────────────────────────────────────────────
1352
+
1353
+/// Recursively sum the sizes of all files under `path`, following symlinks.
1354
+/// Used by the ACP download-progress poller to report bytes-on-disk before
1355
+/// hf-hub renames the staging files to their final blob names.
1356
+fn dir_size_recursive(path: &std::path::Path) -> u64 {
1357
+ let mut total: u64 = 0;
1358
+ let Ok(entries) = std::fs::read_dir(path) else {
1359
+ return 0;
1360
+ };
1361
+ for entry in entries.flatten() {
1362
+ let entry_path = entry.path();
1363
+ if entry_path.is_dir() {
1364
+ total += dir_size_recursive(&entry_path);
1365
+ } else if let Ok(meta) = entry_path.metadata() {
1366
+ total += meta.len();
1367
+ }
1368
+ }
1369
+ total
1370
+}
1371
+
1372
+/// Format a byte count as a human-readable string (B / KB / MB / GB).
1373
+fn format_size_human(bytes: u64) -> String {
1374
+ const GB: u64 = 1_073_741_824;
1375
+ const MB: u64 = 1_048_576;
1376
+ const KB: u64 = 1_024;
1377
+ if bytes >= GB {
1378
+ format!("{:.2} GB", bytes as f64 / GB as f64)
1379
+ } else if bytes >= MB {
1380
+ format!("{:.1} MB", bytes as f64 / MB as f64)
1381
+ } else if bytes >= KB {
1382
+ format!("{:.0} KB", bytes as f64 / KB as f64)
1383
+ } else {
1384
+ format!("{bytes} B")
1385
+ }
1386
+}
1387
+
1388
+/// Build a simple ASCII progress bar string of the given width.
1389
+/// e.g. `[████████░░░░░░░░░░░░]` at 40 %
1390
+fn progress_bar(pct: u8, width: usize) -> String {
1391
+ let filled = ((pct as usize) * width) / 100;
1392
+ let empty = width.saturating_sub(filled);
1393
+ format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
1394
+}
1395
+
1396
// ── Output capture ────────────────────────────────────────────────────────────
1397
1398
/// Redirect **both** stdout and stderr to `$TMPDIR/sigit.log` at the