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));