@hej / sigit / commits / 1acc33c

Show download progress during model switch and support cancellation

- Adds a progress bar and human-readable byte counts for model downloads during model switching in the TUI. - Allows cancelling a model switch with Ctrl+C, suppressing spurious error messages. - Uses a dedicated thread for model loading to avoid blocking the TUI. - Fixes model history clearing on switch in ACP mode. - Uses spawn_local for ACP progress tasks.

paydii committed Apr 26, 2026 at 10:09 UTC 1acc33cc5ef11f42eacc8ab5c004c18269108110
2 files changed +165 -24
src/chat.rs
+162 -22
@@ -165,6 +165,10 @@ struct App {
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 + /// Set to true when the user cancels a model switch with Ctrl+C.
169 + /// Suppresses the "loader task disconnected" error message that would
170 + /// otherwise appear when we drop model_load_rx to abort the switch.
171 + model_load_cancelled: bool,
172
173 // ── Loading-phase state ───────────────────────────────────────────────────
174 /// True while the model is still loading; switches to false on completion.
@@ -186,6 +190,15 @@ struct App {
190 current_model_name: String,
191 /// Whether the currently loaded model supports tool calling.
192 tool_calling: bool,
193 +
194 + // ── Model-switch download progress ────────────────────────────────────────
195 + /// The model_id of the model currently being downloaded/switched to.
196 + /// `None` when no switch is in progress.
197 + switching_model_id: Option<String>,
198 + /// Bytes on disk / expected bytes for the in-progress download.
199 + /// Updated every 100 ms tick while `switching_model` is true and the
200 + /// selected model was not yet cached.
201 + download_progress: Option<(u64, u64)>,
202 }
203
204 const BANNER_ART: &str = "\
@@ -230,6 +243,9 @@ impl App {
243 blink_counter: 0,
244 switching_model: false,
245 pending_tool_calling: None,
246 + model_load_cancelled: false,
247 + switching_model_id: None,
248 + download_progress: None,
249 is_loading: true,
250 load_tick: 0,
251 load_error: None,
@@ -297,6 +313,26 @@ impl App {
313 self.load_tick = self.load_tick.wrapping_add(1);
314 }
315
316 + /// Poll the HF cache directory for the model being switched to and update
317 + /// `download_progress`. Called on every 100 ms tick while switching.
318 + fn poll_download_progress(&mut self) {
319 + let Some(ref model_id) = self.switching_model_id else {
320 + return;
321 + };
322 + let cache_path = onde::hf_cache::model_cache_path(model_id);
323 + let downloaded = cache_path
324 + .as_ref()
325 + .filter(|p| p.exists())
326 + .map(|p| dir_size_recursive(p))
327 + .unwrap_or(0);
328 + let expected = onde::inference::models::SUPPORTED_MODEL_INFO
329 + .iter()
330 + .find(|m| m.id == model_id.as_str())
331 + .map(|m| m.expected_size_bytes)
332 + .unwrap_or(0);
333 + self.download_progress = Some((downloaded, expected));
334 + }
335 +
336 /// Transition from loading phase to normal chat.
337 /// Adds the banner art and welcome messages to the message log.
338 fn finish_loading(&mut self) {
@@ -824,9 +860,32 @@ fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect
860 lines.push(Line::from(spans));
861 }
862
827 - // switching-model indicator (animated spinner)
863 + // switching-model indicator (animated spinner + optional download progress)
864 if app.switching_model {
865 let frame_char = app.switching_frame();
866 +
867 + let status_text = match app.download_progress {
868 + Some((downloaded, expected)) if expected > 0 => {
869 + let pct = ((downloaded as f64 / expected as f64) * 100.0).min(99.0) as u8;
870 + let bar_width: usize = 16;
871 + let filled = (pct as usize * bar_width) / 100;
872 + let empty = bar_width.saturating_sub(filled);
873 + let bar = format!("[{}{}]", "█".repeat(filled), "░".repeat(empty));
874 + format!(
875 + "{frame_char} downloading… {bar} {pct}% ({} / {})",
876 + format_size_human(downloaded),
877 + format_size_human(expected),
878 + )
879 + }
880 + Some((downloaded, 0)) if downloaded > 0 => {
881 + format!(
882 + "{frame_char} downloading… {} received",
883 + format_size_human(downloaded)
884 + )
885 + }
886 + _ => format!("{frame_char} loading model…"),
887 + };
888 +
889 lines.push(Line::from(vec![
890 Span::styled(
891 "siGit > ",
@@ -835,7 +894,7 @@ fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect
894 .add_modifier(Modifier::BOLD),
895 ),
896 Span::styled(
838 - format!("{frame_char} loading model…"),
897 + status_text,
898 Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM),
899 ),
900 ]));
@@ -1180,32 +1239,48 @@ async fn exec_slash<B: ratatui::backend::Backend>(
1239 let (tx, rx) = mpsc::channel(1);
1240 app.model_load_rx = Some(rx);
1241 app.switching_model = true;
1242 + app.switching_model_id = Some(model.config.model_id.clone());
1243 + // Only show download progress for models not yet cached.
1244 + app.download_progress =
1245 + if model.cache_health == ModelCacheHealth::NotDownloaded {
1246 + Some((0, 0))
1247 + } else {
1248 + None
1249 + };
1250
1251 let sampling = SamplingConfig {
1252 max_tokens: Some(model.max_tokens),
1253 ..SamplingConfig::default()
1254 };
1255
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.
1256 + // Use a dedicated OS thread with its own tokio Runtime
1257 + // so that load_gguf_model's internal block_in_place
1258 + // cannot steal the main runtime's worker threads and
1259 + // freeze the TUI draw loop. This mirrors the pattern
1260 + // used at startup in run_interactive / run_acp_server.
1261 let system_prompt = crate::system_prompt_for_model(model.tool_calling);
1262 let engine_handle = Arc::clone(&engine);
1263 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;
1264 + std::thread::spawn(move || {
1265 + let rt = tokio::runtime::Runtime::new()
1266 + .expect("failed to create model-loader runtime");
1267 + let update = rt.block_on(async move {
1268 + match engine_handle
1269 + .load_gguf_model(
1270 + model.config.clone(),
1271 + Some(system_prompt.to_string()),
1272 + Some(sampling),
1273 + )
1274 + .await
1275 + {
1276 + Ok(_) => ModelLoadUpdate::Loaded(model.display_name.clone()),
1277 + Err(err) => ModelLoadUpdate::Error(err.to_string()),
1278 + }
1279 + });
1280 + // blocking_send is fine here — the channel has
1281 + // capacity 1 and the receiver is always alive while
1282 + // switching_model is true.
1283 + let _ = tx.blocking_send(update);
1284 });
1285 // tool_calling is applied when ModelLoadUpdate::Loaded
1286 // arrives in the event loop (see model_load_rx handler).
@@ -1388,6 +1463,9 @@ async fn event_loop<B: ratatui::backend::Backend>(
1463 app.tool_calling = tc;
1464 }
1465 app.switching_model = false;
1466 + app.switching_model_id = None;
1467 + app.download_progress = None;
1468 + app.model_load_cancelled = false;
1469 app.model_load_rx = None;
1470 app.current_model_name = model_name.clone();
1471
@@ -1425,17 +1503,26 @@ async fn event_loop<B: ratatui::backend::Backend>(
1503 }
1504 Ok(ModelLoadUpdate::Error(error)) => {
1505 app.switching_model = false;
1506 + app.switching_model_id = None;
1507 + app.download_progress = None;
1508 + app.model_load_cancelled = false;
1509 app.model_load_rx = None;
1510 app.messages
1511 .push(ChatMessage::system(format!("error loading model: {error}")));
1512 }
1513 Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
1514 Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
1515 + let was_cancelled = app.model_load_cancelled;
1516 app.switching_model = false;
1517 + app.switching_model_id = None;
1518 + app.download_progress = None;
1519 + app.model_load_cancelled = false;
1520 app.model_load_rx = None;
1436 - app.messages.push(ChatMessage::system(
1437 - "error loading model: loader task disconnected".to_string(),
1438 - ));
1521 + if !was_cancelled {
1522 + app.messages.push(ChatMessage::system(
1523 + "error loading model: loader task disconnected".to_string(),
1524 + ));
1525 + }
1526 }
1527 }
1528 }
@@ -1513,6 +1600,11 @@ async fn event_loop<B: ratatui::backend::Backend>(
1600 }
1601 } => {
1602 app.tick_thinking();
1603 + // Refresh download-progress bytes from the HF cache dir so
1604 + // the progress bar in render_messages stays current.
1605 + if app.switching_model {
1606 + app.poll_download_progress();
1607 + }
1608 }
1609
1610 // ── Terminal events ───────────────────────────────────────────
@@ -1551,6 +1643,18 @@ async fn event_loop<B: ratatui::backend::Backend>(
1643 app.stop_thinking();
1644 app.messages.push(ChatMessage::system("(cancelled)"));
1645 }
1646 + if app.switching_model {
1647 + // Mark as cancelled before dropping the
1648 + // receiver so the Disconnected arm in the
1649 + // model_load_rx handler stays silent.
1650 + app.model_load_cancelled = true;
1651 + app.switching_model = false;
1652 + app.switching_model_id = None;
1653 + app.download_progress = None;
1654 + app.model_load_rx = None;
1655 + app.messages
1656 + .push(ChatMessage::system("(download cancelled — model switch aborted)"));
1657 + }
1658 }
1659 }
1660 continue;
@@ -1584,6 +1688,42 @@ async fn event_loop<B: ratatui::backend::Backend>(
1688 Ok(())
1689 }
1690
1691 +// ── Download progress helpers (TUI) ──────────────────────────────────────────
1692 +
1693 +/// Recursively sum the on-disk size of all files under `path`, following
1694 +/// symlinks so hf-hub's blob layout is counted correctly.
1695 +fn dir_size_recursive(path: &std::path::Path) -> u64 {
1696 + let mut total: u64 = 0;
1697 + let Ok(entries) = std::fs::read_dir(path) else {
1698 + return 0;
1699 + };
1700 + for entry in entries.flatten() {
1701 + let entry_path = entry.path();
1702 + if entry_path.is_dir() {
1703 + total += dir_size_recursive(&entry_path);
1704 + } else if let Ok(meta) = entry_path.metadata() {
1705 + total += meta.len();
1706 + }
1707 + }
1708 + total
1709 +}
1710 +
1711 +/// Format a byte count as a terse human-readable string.
1712 +fn format_size_human(bytes: u64) -> String {
1713 + const GB: u64 = 1_073_741_824;
1714 + const MB: u64 = 1_048_576;
1715 + const KB: u64 = 1_024;
1716 + if bytes >= GB {
1717 + format!("{:.2} GB", bytes as f64 / GB as f64)
1718 + } else if bytes >= MB {
1719 + format!("{:.1} MB", bytes as f64 / MB as f64)
1720 + } else if bytes >= KB {
1721 + format!("{:.0} KB", bytes as f64 / KB as f64)
1722 + } else {
1723 + format!("{bytes} B")
1724 + }
1725 +}
1726 +
1727 #[cfg(test)]
1728 mod tests {
1729 use super::strip_think_blocks;
src/main.rs
+3 -2
@@ -646,6 +646,7 @@ async fn exec_slash_acp(
646
647 match agent.switch_model_by_id(&model.config.model_id).await {
648 Ok(new_config) => {
649 + agent.engine.clear_history().await;
650 agent
651 .send_assistant_message(
652 session_id,
@@ -1211,7 +1212,7 @@ impl Agent for SiGitAgent {
1212 let poller_model_id = model_id_owned.clone();
1213 let poller_stop = Arc::clone(&stop_flag);
1214
1214 - tokio::spawn(async move {
1215 + tokio::task::spawn_local(async move {
1216 let cache_path = onde::hf_cache::model_cache_path(&poller_model_id);
1217 let mut interval = tokio::time::interval(std::time::Duration::from_secs(4));
1218 interval.tick().await; // consume the immediate first tick
@@ -1281,7 +1282,7 @@ impl Agent for SiGitAgent {
1282 let spinner_stop = Arc::clone(&stop_flag);
1283 let load_start = std::time::Instant::now();
1284
1284 - tokio::spawn(async move {
1285 + tokio::task::spawn_local(async move {
1286 const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1287 let mut tick: usize = 0;
1288 let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));