@setoelkahfi / sigit / commits / f543385

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
index 108c092..c6cfaac 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -162,6 +162,9 @@ struct App { blink_counter: u8, /// True while a model switch is in progress. switching_model: bool, + /// Tool-calling flag for the model currently being loaded in the background. + /// Applied to `app.tool_calling` when `ModelLoadUpdate::Loaded` arrives. + pending_tool_calling: Option<bool>, // ── Loading-phase state ─────────────────────────────────────────────────── /// True while the model is still loading; switches to false on completion. @@ -226,6 +229,7 @@ impl App { blink_on: true, blink_counter: 0, switching_model: false, + pending_tool_calling: None, is_loading: true, load_tick: 0, load_error: None, @@ -1107,7 +1111,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<String> { async fn exec_slash<B: ratatui::backend::Backend>( app: &mut App, cmd: SlashCommand, - engine: &ChatEngine, + engine: Arc<ChatEngine>, terminal: &mut ratatui::Terminal<B>, ) { match cmd { @@ -1130,7 +1134,7 @@ async fn exec_slash<B: ratatui::backend::Backend>( ))); } SlashCommand::Status => { - let info = engine.info().await; + let info = engine.as_ref().info().await; let model = info.model_name.as_deref().unwrap_or("(none)"); let mem = info.approx_memory.as_deref().unwrap_or("unknown"); app.messages.push(ChatMessage::system(format!( @@ -1140,7 +1144,7 @@ async fn exec_slash<B: ratatui::backend::Backend>( } SlashCommand::Models(selection) => match selection { None => { - app.open_model_picker(engine); + app.open_model_picker(&engine); } Some(n) => { let idx = n.saturating_sub(1); @@ -1182,28 +1186,30 @@ async fn exec_slash<B: ratatui::backend::Backend>( ..SamplingConfig::default() }; - // load_gguf_model unloads any existing model internally before - // loading the new one. Calling unload_model() explicitly first - // would create a window where no model is loaded — if a message - // arrived in that gap it would fail with NoModelLoaded. + // Spawn onto a background task so the event loop keeps + // running (and the spinner keeps animating) during the + // download + load — which can take several minutes for + // a large model fetched from HuggingFace for the first time. let system_prompt = crate::system_prompt_for_model(model.tool_calling); - let update = match engine - .load_gguf_model( - model.config.clone(), - Some(system_prompt.to_string()), - Some(sampling), - ) - .await - { - Ok(_) => { - engine.clear_history().await; - app.tool_calling = model.tool_calling; - ModelLoadUpdate::Loaded(model.display_name.clone()) - } - Err(err) => ModelLoadUpdate::Error(err.to_string()), - }; - - let _ = tx.send(update).await; + let engine_handle = Arc::clone(&engine); + let tool_calling = model.tool_calling; + tokio::spawn(async move { + let update = match engine_handle + .load_gguf_model( + model.config.clone(), + Some(system_prompt.to_string()), + Some(sampling), + ) + .await + { + Ok(_) => ModelLoadUpdate::Loaded(model.display_name.clone()), + Err(err) => ModelLoadUpdate::Error(err.to_string()), + }; + let _ = tx.send(update).await; + }); + // tool_calling is applied when ModelLoadUpdate::Loaded + // arrives in the event loop (see model_load_rx handler). + app.pending_tool_calling = Some(tool_calling); } } } @@ -1377,6 +1383,10 @@ async fn event_loop<B: ratatui::backend::Backend>( if let Some(rx) = app.model_load_rx.as_mut() { match rx.try_recv() { Ok(ModelLoadUpdate::Loaded(model_name)) => { + engine.clear_history().await; + if let Some(tc) = app.pending_tool_calling.take() { + app.tool_calling = tc; + } app.switching_model = false; app.model_load_rx = None; app.current_model_name = model_name.clone(); @@ -1548,7 +1558,7 @@ async fn event_loop<B: ratatui::backend::Backend>( if let Some(text) = handle_key(&mut app, key) { if let Some(cmd) = parse_slash(&text) { - exec_slash(&mut app, cmd, &engine, terminal).await; + exec_slash(&mut app, cmd, Arc::clone(&engine), terminal).await; continue; }
src/main.rs
+219 -1
index 83f014c..b1735ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -66,6 +66,7 @@ use agent_client_protocol::{ use futures::future::LocalBoxFuture; use onde::inference::{ChatEngine, GgufModelConfig, ToolDefinition, ToolResult}; use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; use tokio::sync::mpsc; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use tracing_subscriber::{EnvFilter, fmt as tracing_fmt}; @@ -1163,7 +1164,179 @@ impl Agent for SiGitAgent { } let model_id = args.value.0.as_ref(); - let _new_config = self.switch_model_by_id(model_id).await?; + + // Check if this model needs to be downloaded first so we can show + // a progress indicator in Zed while the download + load is happening. + let needs_download = models::build_model_picker_items() + .into_iter() + .find(|item| item.config.model_id == model_id) + .map(|item| item.cache_health == setup::ModelCacheHealth::NotDownloaded) + .unwrap_or(false); + + // Spawn a progress-poller task that sends periodic download status + // messages to Zed via the notification channel. A shared flag lets + // us stop the poller once the load finishes. + let stop_flag = Arc::new(AtomicBool::new(false)); + + if needs_download { + // Send the initial "downloading" banner immediately. + let model_id_owned = model_id.to_string(); + let expected_bytes = onde::inference::models::SUPPORTED_MODEL_INFO + .iter() + .find(|m| m.id == model_id_owned) + .map(|m| m.expected_size_bytes) + .unwrap_or(0); + + let display_name = models::build_model_picker_items() + .into_iter() + .find(|item| item.config.model_id == model_id_owned) + .map(|item| item.display_name.clone()) + .unwrap_or_else(|| model_id_owned.clone()); + + let size_hint = if expected_bytes > 0 { + format!(" (~{})", format_size_human(expected_bytes)) + } else { + String::new() + }; + + self.send_assistant_message( + args.session_id.clone(), + format!("⏬ Downloading {display_name}{size_hint}… this may take a few minutes."), + ) + .await; + + // Poller: every 4 seconds report bytes-on-disk / expected. + let poller_tx = self.notification_tx.clone(); + let poller_session = args.session_id.clone(); + let poller_model_id = model_id_owned.clone(); + let poller_stop = Arc::clone(&stop_flag); + + tokio::spawn(async move { + let cache_path = onde::hf_cache::model_cache_path(&poller_model_id); + let mut interval = tokio::time::interval(std::time::Duration::from_secs(4)); + interval.tick().await; // consume the immediate first tick + + while !poller_stop.load(Ordering::Relaxed) { + interval.tick().await; + + if poller_stop.load(Ordering::Relaxed) { + break; + } + + let downloaded = cache_path + .as_ref() + .filter(|p| p.exists()) + .map(|p| dir_size_recursive(p)) + .unwrap_or(0); + + let msg = if expected_bytes > 0 { + let pct = + ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8; + let bar = progress_bar(pct, 20); + format!( + "⏬ {display_name} — {bar} {pct}% ({} / {})", + format_size_human(downloaded), + format_size_human(expected_bytes), + ) + } else { + format!( + "⏬ {display_name} — {} downloaded…", + format_size_human(downloaded) + ) + }; + + let notification = SessionNotification::new( + poller_session.clone(), + SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from( + msg, + ))), + ); + if poller_tx.send(notification).await.is_err() { + break; + } + } + }); + } + + // For already-cached models, send a "loading" message and a spinner + // so the user sees activity while mistralrs loads the weights (~10-30 s). + if !needs_download { + let cached_display_name = models::build_model_picker_items() + .into_iter() + .find(|item| item.config.model_id == model_id) + .map(|item| item.display_name.clone()) + .unwrap_or_else(|| model_id.to_string()); + + self.send_assistant_message( + args.session_id.clone(), + format!("⏳ Loading {cached_display_name}…"), + ) + .await; + + // Spinner poller: send an elapsed-time update every 5 seconds so + // the user can tell siGit is still working. + let spinner_tx = self.notification_tx.clone(); + let spinner_session = args.session_id.clone(); + let spinner_name = cached_display_name.clone(); + let spinner_stop = Arc::clone(&stop_flag); + let load_start = std::time::Instant::now(); + + tokio::spawn(async move { + const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let mut tick: usize = 0; + let mut interval = tokio::time::interval(std::time::Duration::from_secs(5)); + interval.tick().await; // consume the immediate first tick + + while !spinner_stop.load(Ordering::Relaxed) { + interval.tick().await; + + if spinner_stop.load(Ordering::Relaxed) { + break; + } + + let elapsed = load_start.elapsed(); + let elapsed_str = if elapsed.as_secs() >= 60 { + format!("{}m {:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60) + } else { + format!("{}s", elapsed.as_secs()) + }; + let frame = SPINNER[tick % SPINNER.len()]; + tick += 1; + + let msg = format!("{frame} Loading {spinner_name}… ({elapsed_str})"); + let notification = SessionNotification::new( + spinner_session.clone(), + SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from( + msg, + ))), + ); + if spinner_tx.send(notification).await.is_err() { + break; + } + } + }); + } + + let switch_result = self.switch_model_by_id(model_id).await; + + // Stop the progress / spinner poller regardless of success/failure. + stop_flag.store(true, Ordering::Relaxed); + + let new_config = switch_result?; + + if needs_download { + self.send_assistant_message( + args.session_id.clone(), + format!("✓ {} downloaded and loaded.", new_config.display_name), + ) + .await; + } else { + self.send_assistant_message( + args.session_id.clone(), + format!("✓ Switched to {}.", new_config.display_name), + ) + .await; + } let config_options = { let guard = self.current_model.lock().unwrap(); @@ -1175,6 +1348,51 @@ impl Agent for SiGitAgent { } } +// ── Download progress helpers ───────────────────────────────────────────────── + +/// Recursively sum the sizes of all files under `path`, following symlinks. +/// Used by the ACP download-progress poller to report bytes-on-disk before +/// hf-hub renames the staging files to their final blob names. +fn dir_size_recursive(path: &std::path::Path) -> u64 { + let mut total: u64 = 0; + let Ok(entries) = std::fs::read_dir(path) else { + return 0; + }; + for entry in entries.flatten() { + let entry_path = entry.path(); + if entry_path.is_dir() { + total += dir_size_recursive(&entry_path); + } else if let Ok(meta) = entry_path.metadata() { + total += meta.len(); + } + } + total +} + +/// Format a byte count as a human-readable string (B / KB / MB / GB). +fn format_size_human(bytes: u64) -> String { + const GB: u64 = 1_073_741_824; + const MB: u64 = 1_048_576; + const KB: u64 = 1_024; + if bytes >= GB { + format!("{:.2} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.1} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.0} KB", bytes as f64 / KB as f64) + } else { + format!("{bytes} B") + } +} + +/// Build a simple ASCII progress bar string of the given width. +/// e.g. `[████████░░░░░░░░░░░░]` at 40 % +fn progress_bar(pct: u8, width: usize) -> String { + let filled = ((pct as usize) * width) / 100; + let empty = width.saturating_sub(filled); + format!("[{}{}]", "█".repeat(filled), "░".repeat(empty)) +} + // ── Output capture ──────────────────────────────────────────────────────────── /// Redirect **both** stdout and stderr to `$TMPDIR/sigit.log` at the