@hej / sigit / commits / 1e89426

Track selected model by ID and file, add cache health badges

paydii committed Apr 24, 2026 at 17:42 UTC 1e894268b3ca1d9e5429a011824be3e4d3cdc89e
4 files changed +449 -49
src/chat.rs
+81 -7
@@ -21,7 +21,7 @@ use onde::inference::{
21 ChatEngine, GgufModelConfig, SamplingConfig, StreamChunk, ToolDefinition, ToolResult,
22 };
23
24 -use crate::setup::DiscoveredModel;
24 +use crate::setup::{DiscoveredModel, ModelCacheHealth};
25 use ratatui::{
26 Frame,
27 layout::{Constraint, Layout, Position},
@@ -262,17 +262,31 @@ impl App {
262 }
263
264 fn open_model_picker(&mut self, engine: &ChatEngine) {
265 - let current = crate::setup::load_selected_model_name().unwrap_or_else(|| {
265 + let current = crate::setup::load_selected_model();
266 + let current_name = crate::setup::load_selected_model_name().unwrap_or_else(|| {
267 futures::executor::block_on(engine.info())
268 .model_name
269 .unwrap_or_else(|| self.current_model_name.clone())
270 });
271
272 self.model_picker_items = build_model_picker_items();
272 - self.model_picker_index = self
273 - .model_picker_items
274 - .iter()
275 - .position(|item| item.display_name == current)
273 + self.model_picker_index = current
274 + .as_ref()
275 + .and_then(|selected| {
276 + self.model_picker_items.iter().position(|item| {
277 + item.config.model_id == selected.model_id
278 + && item
279 + .config
280 + .files
281 + .iter()
282 + .any(|file| file == &selected.gguf_file)
283 + })
284 + })
285 + .or_else(|| {
286 + self.model_picker_items
287 + .iter()
288 + .position(|item| item.display_name == current_name)
289 + })
290 .unwrap_or(0);
291 self.show_model_picker = true;
292 }
@@ -374,6 +388,7 @@ pub(crate) struct ModelPickerItem {
388 local_path: Option<String>,
389 brand_mark: &'static str,
390 source: ModelSource,
391 + cache_health: ModelCacheHealth,
392 }
393
394 pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
@@ -400,6 +415,7 @@ pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
415 local_path: None,
416 brand_mark: "◎",
417 source: ModelSource::Fallback,
418 + cache_health: ModelCacheHealth::Complete,
419 });
420 }
421
@@ -420,6 +436,8 @@ fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPicker
436
437 let config = match model.model_id.as_str() {
438 "bartowski/Qwen_Qwen3-4B-GGUF" => GgufModelConfig::qwen3_4b(),
439 + "bartowski/Qwen2.5-3B-Instruct-GGUF" => GgufModelConfig::qwen25_3b(),
440 + "bartowski/Qwen2.5-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_1_5b(),
441 "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_3b(),
442 "bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_1_5b(),
443 _ => return None,
@@ -442,6 +460,7 @@ fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPicker
460 } else {
461 ModelSource::HuggingFace
462 },
463 + cache_health: model.cache_health,
464 })
465 }
466
@@ -503,7 +522,15 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect
522 } else {
523 ""
524 };
525 + let health_badge = match item.cache_health {
526 + ModelCacheHealth::Complete => "",
527 + ModelCacheHealth::Incomplete => " ! incomplete cache",
528 + };
529 let current_badge = if current { " ← current" } else { "" };
530 + let disabled_badge = match item.cache_health {
531 + ModelCacheHealth::Complete => "",
532 + ModelCacheHealth::Incomplete => " (unselectable)",
533 + };
534 let source = format!(" [{} {}]", item.brand_mark, item.source_label);
535
536 let base_style = if selected {
@@ -522,6 +549,12 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect
549 }
550 };
551
552 + let health_style = if selected {
553 + Style::default().fg(Color::Red).bg(Color::White)
554 + } else {
555 + Style::default().fg(Color::Red)
556 + };
557 +
558 lines.push(Line::from(vec![
559 Span::styled(
560 format!("{marker}{} {}", item.display_name, item.description),
@@ -535,6 +568,15 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect
568 Style::default().fg(Color::Green)
569 },
570 ),
571 + Span::styled(health_badge.to_string(), health_style),
572 + Span::styled(
573 + disabled_badge.to_string(),
574 + if selected {
575 + Style::default().fg(Color::DarkGray).bg(Color::White)
576 + } else {
577 + Style::default().fg(Color::DarkGray)
578 + },
579 + ),
580 Span::styled(
581 current_badge.to_string(),
582 if selected {
@@ -1085,6 +1127,15 @@ async fn exec_slash<B: ratatui::backend::Backend>(
1127 )));
1128 }
1129 Some(model) => {
1130 + if model.cache_health == ModelCacheHealth::Incomplete {
1131 + app.close_model_picker();
1132 + app.messages.push(ChatMessage::system(format!(
1133 + "error: {} has an incomplete local cache and cannot be selected yet.",
1134 + model.display_name
1135 + )));
1136 + return;
1137 + }
1138 +
1139 app.close_model_picker();
1140 app.messages.push(ChatMessage::system(format!(
1141 "Loading {}…",
@@ -1286,7 +1337,30 @@ async fn event_loop<B: ratatui::backend::Backend>(
1337 app.switching_model = false;
1338 app.model_load_rx = None;
1339 app.current_model_name = model_name.clone();
1289 - if let Err(error) = crate::setup::save_selected_model_name(&model_name) {
1340 +
1341 + let save_result = app
1342 + .model_picker_items
1343 + .iter()
1344 + .find(|item| item.display_name == model_name)
1345 + .map(|item| crate::setup::SelectedModel {
1346 + model_id: item.config.model_id.clone(),
1347 + gguf_file: item
1348 + .config
1349 + .files
1350 + .first()
1351 + .cloned()
1352 + .unwrap_or_else(String::new),
1353 + })
1354 + .filter(|selected| !selected.gguf_file.is_empty())
1355 + .map(|selected| crate::setup::save_selected_model(&selected))
1356 + .unwrap_or_else(|| {
1357 + Err(format!(
1358 + "could not determine a stable identifier for {}",
1359 + model_name
1360 + ))
1361 + });
1362 +
1363 + if let Err(error) = save_result {
1364 app.messages.push(ChatMessage::system(format!(
1365 "warning: switched to {} but could not save the selection: {}",
1366 model_name, error
src/main.rs
+79 -4
@@ -56,7 +56,7 @@ use agent_client_protocol::{
56 Agent, AgentCapabilities, AgentSideConnection, AuthMethod, AuthMethodAgent,
57 AuthenticateRequest, AuthenticateResponse, CancelNotification, Client, ContentBlock,
58 ContentChunk, ForkSessionRequest, ForkSessionResponse, Implementation, InitializeRequest,
59 - InitializeResponse, LoadSessionRequest, LoadSessionResponse, NewSessionRequest,
59 + InitializeResponse, LoadSessionRequest, LoadSessionResponse, Meta, NewSessionRequest,
60 NewSessionResponse, PromptRequest, PromptResponse, ProtocolVersion, SessionCapabilities,
61 SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate, StopReason,
62 };
@@ -161,6 +161,54 @@ fn agent_tools_as_onde() -> Vec<ToolDefinition> {
161 .collect()
162 }
163
164 +fn initialize_meta() -> Meta {
165 + let startup_selection = setup::startup_model_selection();
166 +
167 + let active_model_name = startup_selection
168 + .as_ref()
169 + .map(|selection| selection.display_name.clone())
170 + .unwrap_or_else(|| GgufModelConfig::qwen3_4b().display_name);
171 +
172 + let active_model_id = startup_selection
173 + .as_ref()
174 + .and_then(|selection| selection.selected_model.as_ref())
175 + .map(|selected| selected.model_id.clone())
176 + .unwrap_or_else(|| GgufModelConfig::qwen3_4b().model_id);
177 +
178 + let active_model_file = startup_selection
179 + .as_ref()
180 + .and_then(|selection| selection.selected_model.as_ref())
181 + .map(|selected| selected.gguf_file.clone())
182 + .unwrap_or_else(|| {
183 + GgufModelConfig::qwen3_4b()
184 + .files
185 + .first()
186 + .cloned()
187 + .unwrap_or_default()
188 + });
189 +
190 + let mut model = serde_json::Map::new();
191 + model.insert(
192 + "display_name".to_string(),
193 + serde_json::Value::String(active_model_name),
194 + );
195 + model.insert(
196 + "model_id".to_string(),
197 + serde_json::Value::String(active_model_id),
198 + );
199 + model.insert(
200 + "gguf_file".to_string(),
201 + serde_json::Value::String(active_model_file),
202 + );
203 +
204 + let mut sigit = serde_json::Map::new();
205 + sigit.insert("active_model".to_string(), serde_json::Value::Object(model));
206 +
207 + let mut meta = Meta::new();
208 + meta.insert("sigit".to_string(), serde_json::Value::Object(sigit));
209 + meta
210 +}
211 +
212 // Agent
213
214 struct SiGitAgent {
@@ -199,7 +247,8 @@ impl Agent for SiGitAgent {
247 .session_capabilities(
248 SessionCapabilities::new().fork(SessionForkCapabilities::new()),
249 ),
202 - ))
250 + )
251 + .meta(initialize_meta()))
252 }
253
254 async fn authenticate(
@@ -458,7 +507,20 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
507 .and_then(|selection| {
508 chat::build_model_picker_items()
509 .into_iter()
461 - .find(|item| item.display_name == selection.display_name)
510 + .find(|item| {
511 + selection
512 + .selected_model
513 + .as_ref()
514 + .map(|selected| {
515 + item.config.model_id == selected.model_id
516 + && item
517 + .config
518 + .files
519 + .iter()
520 + .any(|file| file == &selected.gguf_file)
521 + })
522 + .unwrap_or(false)
523 + })
524 .map(|item| item.config)
525 })
526 .unwrap_or_else(GgufModelConfig::platform_default);
@@ -522,7 +584,20 @@ async fn run_acp_server() -> anyhow::Result<()> {
584 .and_then(|selection| {
585 chat::build_model_picker_items()
586 .into_iter()
525 - .find(|item| item.display_name == selection.display_name)
587 + .find(|item| {
588 + selection
589 + .selected_model
590 + .as_ref()
591 + .map(|selected| {
592 + item.config.model_id == selected.model_id
593 + && item
594 + .config
595 + .files
596 + .iter()
597 + .any(|file| file == &selected.gguf_file)
598 + })
599 + .unwrap_or(false)
600 + })
601 .map(|item| item.config)
602 })
603 .unwrap_or_else(GgufModelConfig::qwen3_4b);
src/setup.rs
+289 -32
@@ -69,13 +69,35 @@ pub fn setup_shared_model_cache() {
69 /// Preference key used to remember the last selected model.
70 const SELECTED_MODEL_FILE_NAME: &str = "selected-model.txt";
71
72 +/// Stable persisted identifier for a selected local model.
73 +#[derive(Debug, Clone, PartialEq, Eq)]
74 +pub struct SelectedModel {
75 + /// Hugging Face repo ID, e.g. `bartowski/Qwen_Qwen3-4B-GGUF`.
76 + pub model_id: String,
77 + /// GGUF filename inside the snapshot.
78 + pub gguf_file: String,
79 +}
80 +
81 +impl SelectedModel {
82 + fn from_discovered(model: &DiscoveredModel) -> Self {
83 + Self {
84 + model_id: model.model_id.clone(),
85 + gguf_file: model.gguf_file.clone(),
86 + }
87 + }
88 +
89 + fn matches(&self, model: &DiscoveredModel) -> bool {
90 + self.model_id == model.model_id && self.gguf_file == model.gguf_file
91 + }
92 +}
93 +
94 /// Minimal startup model selection info used before the full UI is running.
95 #[derive(Debug, Clone, PartialEq, Eq)]
96 pub struct StartupModelSelection {
97 /// Human-friendly model name shown in the loading UI.
98 pub display_name: String,
77 - /// The saved model name if one was found.
78 - pub selected_name: Option<String>,
99 + /// The saved model identifier if one was found.
100 + pub selected_model: Option<SelectedModel>,
101 }
102
103 /// A locally discovered GGUF model candidate.
@@ -93,6 +115,14 @@ pub struct DiscoveredModel {
115 pub gguf_path: PathBuf,
116 /// True when the model came from the Onde app group cache.
117 pub from_app_group: bool,
118 + /// Whether the snapshot looks complete enough to load.
119 + pub cache_health: ModelCacheHealth,
120 +}
121 +
122 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
123 +pub enum ModelCacheHealth {
124 + Complete,
125 + Incomplete,
126 }
127
128 /// Return all locally discovered GGUF models.
@@ -112,9 +142,13 @@ pub fn discover_local_models() -> Vec<DiscoveredModel> {
142 }
143
144 models.sort_by(|left, right| {
115 - left.display_name
116 - .to_lowercase()
117 - .cmp(&right.display_name.to_lowercase())
145 + left.cache_health
146 + .cmp(&right.cache_health)
147 + .then_with(|| {
148 + left.display_name
149 + .to_lowercase()
150 + .cmp(&right.display_name.to_lowercase())
151 + })
152 .then_with(|| left.model_id.cmp(&right.model_id))
153 .then_with(|| left.gguf_file.cmp(&right.gguf_file))
154 });
@@ -172,26 +206,49 @@ fn collect_models_from_cache_root(
206 Err(_) => continue,
207 };
208
209 + let mut has_config_json = false;
210 + let mut has_tokenizer = false;
211 + let mut gguf_files = Vec::new();
212 +
213 for file in files.flatten() {
214 let file_path = file.path();
215 if !file_path.is_file() {
216 continue;
217 }
218
219 + let file_name = match file.file_name().to_str() {
220 + Some(name) => name.to_string(),
221 + None => continue,
222 + };
223 +
224 + if file_name == "config.json" {
225 + has_config_json = true;
226 + }
227 +
228 + if file_name == "tokenizer.json"
229 + || file_name == "tokenizer.model"
230 + || file_name == "tokenizer_config.json"
231 + {
232 + has_tokenizer = true;
233 + }
234 +
235 let extension = file_path
236 .extension()
237 .and_then(|ext| ext.to_str())
238 .unwrap_or_default();
239
186 - if !extension.eq_ignore_ascii_case("gguf") {
187 - continue;
240 + if extension.eq_ignore_ascii_case("gguf") {
241 + gguf_files.push((file_name, file_path));
242 }
243 + }
244
190 - let gguf_file = match file.file_name().to_str() {
191 - Some(name) => name.to_string(),
192 - None => continue,
193 - };
245 + let cache_health = if has_config_json && has_tokenizer {
246 + ModelCacheHealth::Complete
247 + } else {
248 + ModelCacheHealth::Incomplete
249 + };
250
251 + for (gguf_file, file_path) in gguf_files {
252 models.push(DiscoveredModel {
253 display_name: display_name_for_model(&model_id, &gguf_file),
254 model_id: model_id.clone(),
@@ -199,6 +256,7 @@ fn collect_models_from_cache_root(
256 snapshot_path: snapshot_path.clone(),
257 gguf_path: file_path,
258 from_app_group,
259 + cache_health,
260 });
261 }
262 }
@@ -249,11 +307,34 @@ fn hf_cache_root() -> Option<PathBuf> {
307 path.is_dir().then_some(path)
308 }
309
252 -pub fn load_selected_model_name() -> Option<String> {
310 +pub fn load_selected_model() -> Option<SelectedModel> {
311 let path = selected_model_file_path()?;
312 let contents = std::fs::read_to_string(path).ok()?;
313 let trimmed = contents.trim();
256 - (!trimmed.is_empty()).then(|| trimmed.to_string())
314 + if trimmed.is_empty() {
315 + return None;
316 + }
317 +
318 + let mut parts = trimmed.splitn(2, '\n');
319 + let model_id = parts.next()?.trim();
320 + let gguf_file = parts.next()?.trim();
321 +
322 + if model_id.is_empty() || gguf_file.is_empty() {
323 + return None;
324 + }
325 +
326 + Some(SelectedModel {
327 + model_id: model_id.to_string(),
328 + gguf_file: gguf_file.to_string(),
329 + })
330 +}
331 +
332 +pub fn load_selected_model_name() -> Option<String> {
333 + let selected = load_selected_model()?;
334 + discover_local_models()
335 + .into_iter()
336 + .find(|model| selected.matches(model))
337 + .map(|model| model.display_name)
338 }
339
340 /// Pick the model name siGit should try to load at startup.
@@ -269,28 +350,31 @@ pub fn load_selected_model_name() -> Option<String> {
350 pub fn startup_model_selection() -> Option<StartupModelSelection> {
351 let discovered = discover_local_models();
352
272 - if let Some(saved_name) = load_selected_model_name() {
273 - if discovered
274 - .iter()
275 - .any(|model| model.display_name == saved_name)
276 - {
277 - return Some(StartupModelSelection {
278 - display_name: saved_name.clone(),
279 - selected_name: Some(saved_name),
280 - });
281 - }
353 + if let Some(saved_model) = load_selected_model()
354 + && let Some(model) = discovered.iter().find(|model| {
355 + saved_model.matches(model) && model.cache_health == ModelCacheHealth::Complete
356 + })
357 + {
358 + return Some(StartupModelSelection {
359 + display_name: model.display_name.clone(),
360 + selected_model: Some(saved_model),
361 + });
362 }
363
284 - discovered.into_iter().next().map(|model| {
285 - let _ = save_selected_model_name(&model.display_name);
286 - StartupModelSelection {
287 - display_name: model.display_name.clone(),
288 - selected_name: Some(model.display_name),
289 - }
290 - })
364 + discovered
365 + .into_iter()
366 + .find(|model| model.cache_health == ModelCacheHealth::Complete)
367 + .map(|model| {
368 + let selected_model = SelectedModel::from_discovered(&model);
369 + let _ = save_selected_model(&selected_model);
370 + StartupModelSelection {
371 + display_name: model.display_name.clone(),
372 + selected_model: Some(selected_model),
373 + }
374 + })
375 }
376
293 -pub fn save_selected_model_name(model_name: &str) -> Result<(), String> {
377 +pub fn save_selected_model(selected_model: &SelectedModel) -> Result<(), String> {
378 let path = selected_model_file_path()
379 .ok_or_else(|| "Could not determine where to store the selected model.".to_string())?;
380
@@ -301,7 +385,12 @@ pub fn save_selected_model_name(model_name: &str) -> Result<(), String> {
385 .map_err(|error| format!("Could not create preferences directory: {error}"))?;
386 }
387
304 - std::fs::write(&path, model_name)
388 + let contents = format!(
389 + "{}\n{}\n",
390 + selected_model.model_id, selected_model.gguf_file
391 + );
392 +
393 + std::fs::write(&path, contents)
394 .map_err(|error| format!("Could not save selected model: {error}"))
395 }
396
@@ -355,3 +444,171 @@ fn resolve_shared_container() -> Option<PathBuf> {
444 fn resolve_shared_container() -> Option<PathBuf> {
445 None
446 }
447 +
448 +#[cfg(test)]
449 +mod tests {
450 + use super::*;
451 + use std::sync::{Mutex, OnceLock};
452 +
453 + fn env_lock() -> &'static Mutex<()> {
454 + static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
455 + LOCK.get_or_init(|| Mutex::new(()))
456 + }
457 +
458 + fn unique_temp_dir(name: &str) -> PathBuf {
459 + let nanos = std::time::SystemTime::now()
460 + .duration_since(std::time::UNIX_EPOCH)
461 + .expect("system time before unix epoch")
462 + .as_nanos();
463 +
464 + std::env::temp_dir().join(format!("sigit-setup-tests-{name}-{nanos}"))
465 + }
466 +
467 + fn create_snapshot(
468 + cache_root: &Path,
469 + model_id: &str,
470 + snapshot_name: &str,
471 + gguf_file: &str,
472 + complete: bool,
473 + ) -> PathBuf {
474 + let repo_dir = cache_root.join(format!("models--{}", model_id.replace('/', "--")));
475 + let snapshot_dir = repo_dir.join("snapshots").join(snapshot_name);
476 + std::fs::create_dir_all(&snapshot_dir).expect("create snapshot dir");
477 + std::fs::write(snapshot_dir.join(gguf_file), b"gguf").expect("write gguf");
478 +
479 + if complete {
480 + std::fs::write(snapshot_dir.join("config.json"), b"{}").expect("write config");
481 + std::fs::write(snapshot_dir.join("tokenizer.json"), b"{}").expect("write tokenizer");
482 + }
483 +
484 + snapshot_dir
485 + }
486 +
487 + fn with_test_env<T>(hf_hub_cache: &Path, hf_home: &Path, f: impl FnOnce() -> T) -> T {
488 + let _guard = env_lock().lock().expect("lock env");
489 +
490 + let old_hf_hub_cache = std::env::var_os("HF_HUB_CACHE");
491 + let old_hf_home = std::env::var_os("HF_HOME");
492 + let old_home = std::env::var_os("HOME");
493 +
494 + // SAFETY: tests serialize environment mutation with a process-wide mutex.
495 + unsafe {
496 + std::env::set_var("HF_HUB_CACHE", hf_hub_cache);
497 + std::env::set_var("HF_HOME", hf_home);
498 + std::env::set_var("HOME", hf_home);
499 + }
500 +
501 + let result = f();
502 +
503 + // SAFETY: tests serialize environment mutation with a process-wide mutex.
504 + unsafe {
505 + match old_hf_hub_cache {
506 + Some(value) => std::env::set_var("HF_HUB_CACHE", value),
507 + None => std::env::remove_var("HF_HUB_CACHE"),
508 + }
509 + match old_hf_home {
510 + Some(value) => std::env::set_var("HF_HOME", value),
511 + None => std::env::remove_var("HF_HOME"),
512 + }
513 + match old_home {
514 + Some(value) => std::env::set_var("HOME", value),
515 + None => std::env::remove_var("HOME"),
516 + }
517 + }
518 +
519 + result
520 + }
521 +
522 + #[test]
523 + fn discover_local_models_marks_complete_and_incomplete_snapshots() {
524 + let root = unique_temp_dir("discover-health");
525 + let cache_root = root.join("hf-cache");
526 + let hf_home = root.join("hf-home");
527 + std::fs::create_dir_all(&cache_root).expect("create cache root");
528 + std::fs::create_dir_all(&hf_home).expect("create hf home");
529 +
530 + create_snapshot(
531 + &cache_root,
532 + "bartowski/Qwen_Qwen3-4B-GGUF",
533 + "complete",
534 + "Qwen_Qwen3-4B-Q4_K_M.gguf",
535 + true,
536 + );
537 + create_snapshot(
538 + &cache_root,
539 + "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF",
540 + "incomplete",
541 + "Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf",
542 + false,
543 + );
544 +
545 + let models = with_test_env(&cache_root, &hf_home, discover_local_models);
546 +
547 + assert_eq!(models.len(), 2);
548 +
549 + let complete = models
550 + .iter()
551 + .find(|model| model.model_id == "bartowski/Qwen_Qwen3-4B-GGUF")
552 + .expect("complete model discovered");
553 + assert_eq!(complete.cache_health, ModelCacheHealth::Complete);
554 + assert!(!complete.from_app_group);
555 +
556 + let incomplete = models
557 + .iter()
558 + .find(|model| model.model_id == "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF")
559 + .expect("incomplete model discovered");
560 + assert_eq!(incomplete.cache_health, ModelCacheHealth::Incomplete);
561 + assert!(!incomplete.from_app_group);
562 +
563 + std::fs::remove_dir_all(root).expect("remove temp dir");
564 + }
565 +
566 + #[test]
567 + fn startup_model_selection_skips_saved_incomplete_model_and_picks_complete_one() {
568 + let root = unique_temp_dir("startup-selection");
569 + let cache_root = root.join("hf-cache");
570 + let hf_home = root.join("hf-home");
571 + std::fs::create_dir_all(&cache_root).expect("create cache root");
572 + std::fs::create_dir_all(&hf_home).expect("create hf home");
573 +
574 + create_snapshot(
575 + &cache_root,
576 + "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF",
577 + "broken",
578 + "Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf",
579 + false,
580 + );
581 + create_snapshot(
582 + &cache_root,
583 + "bartowski/Qwen_Qwen3-4B-GGUF",
584 + "ready",
585 + "Qwen_Qwen3-4B-Q4_K_M.gguf",
586 + true,
587 + );
588 +
589 + let selection = with_test_env(&cache_root, &hf_home, || {
590 + let selected_path = selected_model_file_path().expect("selected model path");
591 + if let Some(parent) = selected_path.parent() {
592 + std::fs::create_dir_all(parent).expect("create selected model parent");
593 + }
594 +
595 + std::fs::write(
596 + &selected_path,
597 + "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF\nQwen2.5-Coder-3B-Instruct-Q4_K_M.gguf\n",
598 + )
599 + .expect("write selected model");
600 +
601 + startup_model_selection().expect("startup selection")
602 + });
603 +
604 + assert_eq!(
605 + selection.display_name,
606 + "Qwen Qwen3-4B-GGUF — Qwen_Qwen3-4B-Q4_K_M"
607 + );
608 + let selected = selection.selected_model.expect("selected model");
609 + assert_eq!(selected.model_id, "bartowski/Qwen_Qwen3-4B-GGUF");
610 + assert_eq!(selected.gguf_file, "Qwen_Qwen3-4B-Q4_K_M.gguf");
611 +
612 + std::fs::remove_dir_all(root).expect("remove temp dir");
613 + }
614 +}
src/tools.rs
-6
@@ -514,8 +514,6 @@ fn search_file(path: &Path, re: &Regex, matches: &mut Vec<String>) {
514 }
515 }
516
517 -/// ── read_website ─────────────────────────────────────────────────────────────
518 -
517 fn exec_read_website(arguments: &str) -> String {
518 let args: Value = match serde_json::from_str(arguments) {
519 Ok(v) => v,
@@ -630,8 +628,6 @@ fn exec_read_website(arguments: &str) -> String {
628 output
629 }
630
633 -/// ── create_directory ─────────────────────────────────────────────────────────
634 -
631 /// Create a directory and any missing parent directories.
632 fn exec_create_directory(arguments: &str) -> String {
633 let args: Value = match serde_json::from_str(arguments) {
@@ -661,8 +657,6 @@ fn exec_create_directory(arguments: &str) -> String {
657 }
658 }
659
664 -/// ── create_file ──────────────────────────────────────────────────────────────
665 -
660 /// Create a new file with the provided content.
661 ///
662 /// Parent directories are created automatically. Fails if the file already