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
index c6cfaac..fc382fb 100644
--- a/src/chat.rs
+++ b/src/chat.rs
@@ -165,6 +165,10 @@ struct App {
/// 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>,
+ /// Set to true when the user cancels a model switch with Ctrl+C.
+ /// Suppresses the "loader task disconnected" error message that would
+ /// otherwise appear when we drop model_load_rx to abort the switch.
+ model_load_cancelled: bool,
// ── Loading-phase state ───────────────────────────────────────────────────
/// True while the model is still loading; switches to false on completion.
@@ -186,6 +190,15 @@ struct App {
current_model_name: String,
/// Whether the currently loaded model supports tool calling.
tool_calling: bool,
+
+ // ── Model-switch download progress ────────────────────────────────────────
+ /// The model_id of the model currently being downloaded/switched to.
+ /// `None` when no switch is in progress.
+ switching_model_id: Option<String>,
+ /// Bytes on disk / expected bytes for the in-progress download.
+ /// Updated every 100 ms tick while `switching_model` is true and the
+ /// selected model was not yet cached.
+ download_progress: Option<(u64, u64)>,
}
const BANNER_ART: &str = "\
@@ -230,6 +243,9 @@ impl App {
blink_counter: 0,
switching_model: false,
pending_tool_calling: None,
+ model_load_cancelled: false,
+ switching_model_id: None,
+ download_progress: None,
is_loading: true,
load_tick: 0,
load_error: None,
@@ -297,6 +313,26 @@ impl App {
self.load_tick = self.load_tick.wrapping_add(1);
}
+ /// Poll the HF cache directory for the model being switched to and update
+ /// `download_progress`. Called on every 100 ms tick while switching.
+ fn poll_download_progress(&mut self) {
+ let Some(ref model_id) = self.switching_model_id else {
+ return;
+ };
+ let cache_path = onde::hf_cache::model_cache_path(model_id);
+ let downloaded = cache_path
+ .as_ref()
+ .filter(|p| p.exists())
+ .map(|p| dir_size_recursive(p))
+ .unwrap_or(0);
+ let expected = onde::inference::models::SUPPORTED_MODEL_INFO
+ .iter()
+ .find(|m| m.id == model_id.as_str())
+ .map(|m| m.expected_size_bytes)
+ .unwrap_or(0);
+ self.download_progress = Some((downloaded, expected));
+ }
+
/// Transition from loading phase to normal chat.
/// Adds the banner art and welcome messages to the message log.
fn finish_loading(&mut self) {
@@ -824,9 +860,32 @@ fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect
lines.push(Line::from(spans));
}
- // switching-model indicator (animated spinner)
+ // switching-model indicator (animated spinner + optional download progress)
if app.switching_model {
let frame_char = app.switching_frame();
+
+ let status_text = match app.download_progress {
+ Some((downloaded, expected)) if expected > 0 => {
+ let pct = ((downloaded as f64 / expected as f64) * 100.0).min(99.0) as u8;
+ let bar_width: usize = 16;
+ let filled = (pct as usize * bar_width) / 100;
+ let empty = bar_width.saturating_sub(filled);
+ let bar = format!("[{}{}]", "█".repeat(filled), "░".repeat(empty));
+ format!(
+ "{frame_char} downloading… {bar} {pct}% ({} / {})",
+ format_size_human(downloaded),
+ format_size_human(expected),
+ )
+ }
+ Some((downloaded, 0)) if downloaded > 0 => {
+ format!(
+ "{frame_char} downloading… {} received",
+ format_size_human(downloaded)
+ )
+ }
+ _ => format!("{frame_char} loading model…"),
+ };
+
lines.push(Line::from(vec![
Span::styled(
"siGit > ",
@@ -835,7 +894,7 @@ fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect
.add_modifier(Modifier::BOLD),
),
Span::styled(
- format!("{frame_char} loading model…"),
+ status_text,
Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM),
),
]));
@@ -1180,32 +1239,48 @@ async fn exec_slash<B: ratatui::backend::Backend>(
let (tx, rx) = mpsc::channel(1);
app.model_load_rx = Some(rx);
app.switching_model = true;
+ app.switching_model_id = Some(model.config.model_id.clone());
+ // Only show download progress for models not yet cached.
+ app.download_progress =
+ if model.cache_health == ModelCacheHealth::NotDownloaded {
+ Some((0, 0))
+ } else {
+ None
+ };
let sampling = SamplingConfig {
max_tokens: Some(model.max_tokens),
..SamplingConfig::default()
};
- // 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.
+ // Use a dedicated OS thread with its own tokio Runtime
+ // so that load_gguf_model's internal block_in_place
+ // cannot steal the main runtime's worker threads and
+ // freeze the TUI draw loop. This mirrors the pattern
+ // used at startup in run_interactive / run_acp_server.
let system_prompt = crate::system_prompt_for_model(model.tool_calling);
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;
+ std::thread::spawn(move || {
+ let rt = tokio::runtime::Runtime::new()
+ .expect("failed to create model-loader runtime");
+ let update = rt.block_on(async move {
+ 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()),
+ }
+ });
+ // blocking_send is fine here — the channel has
+ // capacity 1 and the receiver is always alive while
+ // switching_model is true.
+ let _ = tx.blocking_send(update);
});
// tool_calling is applied when ModelLoadUpdate::Loaded
// arrives in the event loop (see model_load_rx handler).
@@ -1388,6 +1463,9 @@ async fn event_loop<B: ratatui::backend::Backend>(
app.tool_calling = tc;
}
app.switching_model = false;
+ app.switching_model_id = None;
+ app.download_progress = None;
+ app.model_load_cancelled = false;
app.model_load_rx = None;
app.current_model_name = model_name.clone();
@@ -1425,17 +1503,26 @@ async fn event_loop<B: ratatui::backend::Backend>(
}
Ok(ModelLoadUpdate::Error(error)) => {
app.switching_model = false;
+ app.switching_model_id = None;
+ app.download_progress = None;
+ app.model_load_cancelled = false;
app.model_load_rx = None;
app.messages
.push(ChatMessage::system(format!("error loading model: {error}")));
}
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
+ let was_cancelled = app.model_load_cancelled;
app.switching_model = false;
+ app.switching_model_id = None;
+ app.download_progress = None;
+ app.model_load_cancelled = false;
app.model_load_rx = None;
- app.messages.push(ChatMessage::system(
- "error loading model: loader task disconnected".to_string(),
- ));
+ if !was_cancelled {
+ app.messages.push(ChatMessage::system(
+ "error loading model: loader task disconnected".to_string(),
+ ));
+ }
}
}
}
@@ -1513,6 +1600,11 @@ async fn event_loop<B: ratatui::backend::Backend>(
}
} => {
app.tick_thinking();
+ // Refresh download-progress bytes from the HF cache dir so
+ // the progress bar in render_messages stays current.
+ if app.switching_model {
+ app.poll_download_progress();
+ }
}
// ── Terminal events ───────────────────────────────────────────
@@ -1551,6 +1643,18 @@ async fn event_loop<B: ratatui::backend::Backend>(
app.stop_thinking();
app.messages.push(ChatMessage::system("(cancelled)"));
}
+ if app.switching_model {
+ // Mark as cancelled before dropping the
+ // receiver so the Disconnected arm in the
+ // model_load_rx handler stays silent.
+ app.model_load_cancelled = true;
+ app.switching_model = false;
+ app.switching_model_id = None;
+ app.download_progress = None;
+ app.model_load_rx = None;
+ app.messages
+ .push(ChatMessage::system("(download cancelled — model switch aborted)"));
+ }
}
}
continue;
@@ -1584,6 +1688,42 @@ async fn event_loop<B: ratatui::backend::Backend>(
Ok(())
}
+// ── Download progress helpers (TUI) ──────────────────────────────────────────
+
+/// Recursively sum the on-disk size of all files under `path`, following
+/// symlinks so hf-hub's blob layout is counted correctly.
+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 terse human-readable string.
+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")
+ }
+}
+
#[cfg(test)]
mod tests {
use super::strip_think_blocks;
src/main.rs
+3
-2
index b1735ec..b60fee8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -646,6 +646,7 @@ async fn exec_slash_acp(
match agent.switch_model_by_id(&model.config.model_id).await {
Ok(new_config) => {
+ agent.engine.clear_history().await;
agent
.send_assistant_message(
session_id,
@@ -1211,7 +1212,7 @@ impl Agent for SiGitAgent {
let poller_model_id = model_id_owned.clone();
let poller_stop = Arc::clone(&stop_flag);
- tokio::spawn(async move {
+ tokio::task::spawn_local(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
@@ -1281,7 +1282,7 @@ impl Agent for SiGitAgent {
let spinner_stop = Arc::clone(&stop_flag);
let load_start = std::time::Instant::now();
- tokio::spawn(async move {
+ tokio::task::spawn_local(async move {
const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let mut tick: usize = 0;
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));