@setoelkahfi / sigit / commits / 3b673ef

Add model selection to agent config options and slash commands

- Expose available models as a config option in the agent panel - Implement /models and /models N slash commands to list and switch models - Track current model in SiGitAgent and persist selection - Refactor model discovery and picker item fields for public access

paydii committed Apr 25, 2026 at 20:28 UTC 3b673efc23868b25f02fffbd908a8ef8251d65e4
3 files changed +520 -83
src/chat.rs
+51 -47
index 0faa41d..2aa7a0e 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -27,7 +27,7 @@ use ratatui::{ layout::{Constraint, Layout, Position}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Paragraph, Wrap}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, }; use tokio::sync::mpsc; use tokio::time::{Duration, Instant, interval}; @@ -389,15 +389,14 @@ enum ModelSource { #[derive(Clone)] pub(crate) struct ModelPickerItem { pub(crate) display_name: String, - description: String, - tool_calling: bool, - max_tokens: u64, + pub(crate) description: String, + pub(crate) tool_calling: bool, + pub(crate) max_tokens: u64, pub(crate) config: GgufModelConfig, - source_label: String, - local_path: Option<String>, + pub(crate) source_label: String, brand_mark: &'static str, source: ModelSource, - cache_health: ModelCacheHealth, + pub(crate) cache_health: ModelCacheHealth, } pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> { @@ -421,7 +420,6 @@ pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> { max_tokens, config, source_label: "Platform default".to_string(), - local_path: None, brand_mark: "◎", source: ModelSource::Fallback, cache_health: ModelCacheHealth::Complete, @@ -438,9 +436,9 @@ pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> { fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPickerItem> { let source_label = if model.from_app_group { - "Onde app group".to_string() + "Onde".to_string() } else { - "Hugging Face cache".to_string() + "HuggingFace".to_string() }; let config = match model.model_id.as_str() { @@ -464,7 +462,6 @@ fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPicker max_tokens, config, source_label, - local_path: Some(model.gguf_path.display().to_string()), brand_mark: if model.from_app_group { "◉" } else { "○" }, source: if model.from_app_group { ModelSource::Onde @@ -476,11 +473,16 @@ fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPicker } fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { - let popup = centered_rect(72, 72, area); + let popup = centered_rect(82, 72, area); + + // Erase whatever is behind the popup so the panel is fully readable. + frame.render_widget(Clear, popup); + let block = Block::default() .title(" Select a model… ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)); + .border_style(Style::default().fg(Color::DarkGray)) + .style(Style::default().bg(Color::Black)); let inner = block.inner(popup); frame.render_widget(block, popup); @@ -491,7 +493,7 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect for (index, item) in app.model_picker_items.iter().enumerate() { if last_section != Some(item.source) { if last_section.is_some() { - lines.push(Line::from("")); + lines.push(Line::from("").style(Style::default().bg(Color::Black))); } let (section_mark, section_name, section_style) = match item.source { @@ -500,6 +502,7 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect "Onde Inference", Style::default() .fg(Color::Green) + .bg(Color::Black) .add_modifier(Modifier::BOLD), ), ModelSource::HuggingFace => ( @@ -507,6 +510,7 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect "Hugging Face cache", Style::default() .fg(Color::Cyan) + .bg(Color::Black) .add_modifier(Modifier::BOLD), ), ModelSource::Fallback => ( @@ -514,14 +518,18 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect "Fallback", Style::default() .fg(Color::Yellow) + .bg(Color::Black) .add_modifier(Modifier::BOLD), ), }; - lines.push(Line::from(vec![ - Span::styled(format!("{section_mark} "), section_style), - Span::styled(section_name, section_style), - ])); + lines.push( + Line::from(vec![ + Span::styled(format!("{section_mark} "), section_style), + Span::styled(section_name, section_style), + ]) + .style(Style::default().bg(Color::Black)), + ); last_section = Some(item.source); } @@ -545,25 +553,25 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect let source = format!(" [{} {}]", item.brand_mark, item.source_label); let base_style = if selected { - Style::default().fg(Color::Black).bg(Color::White) + Style::default().fg(Color::Black).bg(Color::Green) } else { - Style::default().fg(Color::White) + Style::default().fg(Color::White).bg(Color::Black) }; let source_style = if selected { - Style::default().fg(Color::DarkGray).bg(Color::White) + Style::default().fg(Color::Black).bg(Color::Green) } else { match item.source { - ModelSource::Onde => Style::default().fg(Color::Green), - ModelSource::HuggingFace => Style::default().fg(Color::Cyan), - ModelSource::Fallback => Style::default().fg(Color::Yellow), + ModelSource::Onde => Style::default().fg(Color::Green).bg(Color::Black), + ModelSource::HuggingFace => Style::default().fg(Color::Cyan).bg(Color::Black), + ModelSource::Fallback => Style::default().fg(Color::Yellow).bg(Color::Black), } }; let health_style = if selected { - Style::default().fg(Color::Red).bg(Color::White) + Style::default().fg(Color::Red).bg(Color::Green) } else { - Style::default().fg(Color::Red) + Style::default().fg(Color::Red).bg(Color::Black) }; lines.push(Line::from(vec![ @@ -574,44 +582,38 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect Span::styled( tool_badge.to_string(), if selected { - Style::default().fg(Color::Green).bg(Color::White) + Style::default().fg(Color::Black).bg(Color::Green) } else { - Style::default().fg(Color::Green) + Style::default().fg(Color::Green).bg(Color::Black) }, ), Span::styled(health_badge.to_string(), health_style), Span::styled( disabled_badge.to_string(), if selected { - Style::default().fg(Color::DarkGray).bg(Color::White) + Style::default().fg(Color::Black).bg(Color::Green) } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(Color::DarkGray).bg(Color::Black) }, ), Span::styled( current_badge.to_string(), if selected { - Style::default().fg(Color::Blue).bg(Color::White) + Style::default().fg(Color::Black).bg(Color::Green) } else { - Style::default().fg(Color::Blue) + Style::default().fg(Color::Cyan).bg(Color::Black) }, ), Span::styled(source, source_style), ])); - - if let Some(path) = &item.local_path { - lines.push(Line::from(Span::styled( - format!(" {}", path), - if selected { - Style::default().fg(Color::DarkGray).bg(Color::White) - } else { - Style::default().fg(Color::DarkGray) - }, - ))); - } } - frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); + frame.render_widget( + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .style(Style::default().bg(Color::Black)), + inner, + ); } fn centered_rect( @@ -1026,7 +1028,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<String> { return None; } KeyCode::Enter => { - return Some("/models __pick__".to_string()); + return Some(format!("/models {}", app.model_picker_index + 1)); } _ => return None, } @@ -1163,8 +1165,10 @@ async fn exec_slash<B: ratatui::backend::Backend>( ..SamplingConfig::default() }; - engine.unload_model().await; - + // 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. let update = match engine .load_gguf_model(model.config.clone(), None, Some(sampling)) .await
src/main.rs
+418 -6
index 00642b3..279e555 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,7 +58,9 @@ use agent_client_protocol::{ ContentChunk, ForkSessionRequest, ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse, LoadSessionRequest, LoadSessionResponse, Meta, NewSessionRequest, NewSessionResponse, PromptRequest, PromptResponse, ProtocolVersion, SessionCapabilities, - SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate, StopReason, + SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption, + SessionConfigValueId, SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate, + SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, }; use futures::future::LocalBoxFuture; use onde::inference::{ChatEngine, GgufModelConfig, ToolDefinition, ToolResult}; @@ -262,16 +264,377 @@ struct SiGitAgent { /// creation. Tool calls use this as `cwd` so file operations target the /// correct project, not wherever the agent process was spawned. session_cwd: std::sync::Mutex<Option<PathBuf>>, + /// The currently loaded model config, used for config_options reporting. + current_model: std::sync::Mutex<GgufModelConfig>, } impl SiGitAgent { - fn new(engine: Arc<ChatEngine>, notification_tx: mpsc::Sender<SessionNotification>) -> Self { + fn new( + engine: Arc<ChatEngine>, + notification_tx: mpsc::Sender<SessionNotification>, + initial_model: GgufModelConfig, + ) -> Self { Self { engine, notification_tx, session_cwd: std::sync::Mutex::new(None), + current_model: std::sync::Mutex::new(initial_model), } } + + async fn send_assistant_message(&self, session_id: SessionId, text: impl Into<String>) { + let notification = SessionNotification::new( + session_id, + SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(text.into()))), + ); + if self.notification_tx.send(notification).await.is_err() { + log::warn!("notification channel closed"); + } + } + + async fn switch_model_by_id( + &self, + model_id: &str, + ) -> agent_client_protocol::Result<GgufModelConfig> { + let (new_config, max_tokens) = resolve_model_config(model_id).ok_or_else(|| { + agent_client_protocol::Error::new( + -32602, + format!("unknown or unavailable model: {model_id}"), + ) + })?; + + log::info!( + "switching model to {} (max_tokens={max_tokens})", + new_config.display_name + ); + + let sampling = SamplingConfig { + max_tokens: Some(max_tokens), + ..SamplingConfig::default() + }; + + // load_gguf_model calls block_in_place internally. Calling it from + // inside the ACP LocalSet (spawn_local) panics with "can call blocking + // only when running on the multi-threaded runtime". Fix: run the + // unload + load on a dedicated OS thread with its own runtime, then + // await the result over a oneshot channel — same pattern used at + // startup in run_acp_server. + let (result_tx, result_rx) = tokio::sync::oneshot::channel::<Result<(), String>>(); + let loader_engine = Arc::clone(&self.engine); + let loader_config = new_config.clone(); + let loader_system_prompt = SYSTEM_PROMPT.to_string(); + let loader_sampling = sampling; + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("failed to create loader runtime"); + let result = rt.block_on(async move { + // 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 prompt + // arrived in that gap it would fail with NoModelLoaded. + loader_engine + .load_gguf_model( + loader_config, + Some(loader_system_prompt), + Some(loader_sampling), + ) + .await + }); + let _ = result_tx.send(result.map(|_| ()).map_err(|e| e.to_string())); + }); + + result_rx + .await + .map_err(|_| agent_client_protocol::Error::new(-32603, "model loader thread crashed"))? + .map_err(|error| { + log::error!("model switch failed: {error}"); + agent_client_protocol::Error::new(-32603, format!("model switch failed: {error}")) + })?; + + if let Some(item) = chat::build_model_picker_items() + .iter() + .find(|item| item.config.model_id == new_config.model_id) + && let Err(err) = setup::save_selected_model(&setup::SelectedModel { + model_id: item.config.model_id.clone(), + gguf_file: item.config.files.first().cloned().unwrap_or_default(), + }) + { + log::warn!("failed to persist model selection: {err}"); + } + + { + let mut guard = self.current_model.lock().unwrap(); + *guard = new_config.clone(); + } + + if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) { + self.engine + .push_history(onde::inference::ChatMessage::system(format!( + "The user's project working directory is {}. \ + Always use absolute paths under this directory for all file \ + and directory operations. This is the root of the project \ + the user has open in their editor.", + cwd.display() + ))) + .await; + } + + Ok(new_config) + } +} + +/// The config option ID used for the model selector in the Zed agent panel. +const MODEL_CONFIG_ID: &str = "sigit-model"; + +/// Build the `SessionConfigOption` list for model selection. +fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionConfigOption> { + let items = chat::build_model_picker_items(); + + let options: Vec<SessionConfigSelectOption> = items + .iter() + .filter(|item| item.cache_health == setup::ModelCacheHealth::Complete) + .map(|item| { + let description = if item.tool_calling { + format!("{} - tool calling", item.description) + } else { + item.description.clone() + }; + let source_badge = match item.source_label.as_str() { + "Onde" => " [◉ Onde]", + "HuggingFace" => " [○ HuggingFace]", + _ => "", + }; + let name = format!("{}{}", item.display_name, source_badge); + SessionConfigSelectOption::new( + SessionConfigValueId::new(item.config.model_id.as_str()), + name, + ) + .description(description) + }) + .collect(); + + if options.is_empty() { + return vec![]; + } + + let current_value = SessionConfigValueId::new(current_model.model_id.as_str()); + + vec![ + SessionConfigOption::select(MODEL_CONFIG_ID, "Model", current_value, options) + .category(SessionConfigOptionCategory::Model) + .description("Select the local LLM model for inference"), + ] +} + +/// Look up the GgufModelConfig for a given model_id value from the picker items. +fn resolve_model_config(model_id: &str) -> Option<(GgufModelConfig, u64)> { + let items = chat::build_model_picker_items(); + items + .into_iter() + .find(|item| { + item.config.model_id == model_id + && item.cache_health == setup::ModelCacheHealth::Complete + }) + .map(|item| (item.config, item.max_tokens)) +} + +#[derive(Debug, Clone)] +enum SlashCommand { + Help, + Clear, + Status, + Models(Option<usize>), + Exit, + Unknown(String), +} + +fn parse_slash(input: &str) -> Option<SlashCommand> { + let trimmed = input.trim(); + if !trimmed.starts_with('/') { + return None; + } + let mut parts = trimmed.splitn(2, char::is_whitespace); + let command = parts.next().unwrap_or(""); + let argument = parts.next().map(str::trim); + Some(match command { + "/help" => SlashCommand::Help, + "/clear" => SlashCommand::Clear, + "/status" => SlashCommand::Status, + "/models" => SlashCommand::Models(argument.and_then(|v| v.parse::<usize>().ok())), + "/exit" | "/quit" | "/q" => SlashCommand::Exit, + other => SlashCommand::Unknown(other.to_string()), + }) +} + +fn format_models_list(current_model: &GgufModelConfig) -> String { + let items = chat::build_model_picker_items(); + if items.is_empty() { + return "No local models found. siGit will use the platform default model.".to_string(); + } + + let mut lines = vec!["Available models:".to_string()]; + let mut last_source: Option<&str> = None; + + for (index, item) in items.iter().enumerate() { + let source_key = match item.source_label.as_str() { + "Onde" => "Onde", + "HuggingFace" => "HuggingFace", + _ => "Fallback", + }; + + if last_source != Some(source_key) { + if last_source.is_some() { + lines.push(String::new()); + } + let section = match source_key { + "Onde" => "Onde Inference", + "HuggingFace" => "Hugging Face cache", + _ => "Fallback", + }; + lines.push(section.to_string()); + last_source = Some(source_key); + } + + let number = index + 1; + let current_badge = if item.config.model_id == current_model.model_id { + " <- current" + } else { + "" + }; + let tool_badge = if item.tool_calling { + " tool calling" + } else { + "" + }; + let health_badge = match item.cache_health { + setup::ModelCacheHealth::Complete => "", + setup::ModelCacheHealth::Incomplete => " ! incomplete cache", + }; + let source = match source_key { + "Onde" => " [Onde]", + "HuggingFace" => " [HuggingFace]", + _ => " [default]", + }; + + lines.push(format!( + "{number}. {} {}{}{}{}{}", + item.display_name, item.description, tool_badge, health_badge, current_badge, source, + )); + } + + lines.push(String::new()); + lines.push("Use /models N to switch models.".to_string()); + lines.join("\n") +} + +async fn exec_slash_acp( + agent: &SiGitAgent, + session_id: SessionId, + command: SlashCommand, +) -> agent_client_protocol::Result<PromptResponse> { + match command { + SlashCommand::Help => { + agent + .send_assistant_message( + session_id, + "/help - show this message\n\ + /models - list available models\n\ + /models N - switch to model N\n\ + /clear - wipe conversation history\n\ + /status - show engine status\n\ + /exit - end this turn", + ) + .await; + } + SlashCommand::Clear => { + let cleared = agent.engine.clear_history().await; + agent + .send_assistant_message( + session_id, + format!("Cleared {cleared} turn(s). History is empty."), + ) + .await; + } + SlashCommand::Status => { + let info = agent.engine.info().await; + let model = info.model_name.as_deref().unwrap_or("(none)"); + let memory = info.approx_memory.as_deref().unwrap_or("unknown"); + agent + .send_assistant_message( + session_id, + format!( + "status: {:?} model: {} memory: {} history: {} turns", + info.status, model, memory, info.history_length, + ), + ) + .await; + } + SlashCommand::Models(None) => { + let current_model = agent.current_model.lock().unwrap().clone(); + agent + .send_assistant_message(session_id, format_models_list(&current_model)) + .await; + } + SlashCommand::Models(Some(number)) => { + let items = chat::build_model_picker_items(); + let index = number.saturating_sub(1); + match items.get(index).cloned() { + None => { + agent + .send_assistant_message( + session_id, + format!("error: no model #{number} - type /models to see the list."), + ) + .await; + } + Some(model) => { + if model.cache_health == setup::ModelCacheHealth::Incomplete { + agent + .send_assistant_message( + session_id, + format!( + "error: {} has an incomplete local cache and cannot be selected yet.", + model.display_name + ), + ) + .await; + } else { + agent + .send_assistant_message( + session_id.clone(), + format!("Loading {}...", model.display_name), + ) + .await; + + let switched = agent.switch_model_by_id(&model.config.model_id).await?; + agent.engine.clear_history().await; + + agent + .send_assistant_message( + session_id, + format!("Switched to {}.", switched.display_name), + ) + .await; + } + } + } + } + SlashCommand::Exit => { + agent + .send_assistant_message( + session_id, + "Use the panel controls to close or switch threads.", + ) + .await; + } + SlashCommand::Unknown(command) => { + agent + .send_assistant_message(session_id, format!("unknown command: {command}")) + .await; + } + } + + Ok(PromptResponse::new(StopReason::EndTurn)) } #[async_trait::async_trait(?Send)] @@ -350,7 +713,12 @@ impl Agent for SiGitAgent { ))) .await; - Ok(LoadSessionResponse::new()) + let config_options = { + let guard = self.current_model.lock().unwrap(); + build_model_config_options(&guard) + }; + + Ok(LoadSessionResponse::new().config_options(config_options)) } async fn fork_session( @@ -393,7 +761,12 @@ impl Agent for SiGitAgent { ))) .await; - Ok(ForkSessionResponse::new(new_id)) + let config_options = { + let guard = self.current_model.lock().unwrap(); + build_model_config_options(&guard) + }; + + Ok(ForkSessionResponse::new(new_id).config_options(config_options)) } async fn new_session( @@ -433,7 +806,12 @@ impl Agent for SiGitAgent { ))) .await; - Ok(NewSessionResponse::new(session_id)) + let config_options = { + let guard = self.current_model.lock().unwrap(); + build_model_config_options(&guard) + }; + + Ok(NewSessionResponse::new(session_id).config_options(config_options)) } async fn prompt(&self, args: PromptRequest) -> agent_client_protocol::Result<PromptResponse> { @@ -583,6 +961,10 @@ impl Agent for SiGitAgent { return Ok(PromptResponse::new(StopReason::EndTurn)); } + if let Some(command) = parse_slash(&user_text) { + return exec_slash_acp(self, session_id, command).await; + } + log::info!( "prompt({}): \"{}\"", session_id, @@ -693,6 +1075,35 @@ impl Agent for SiGitAgent { log::info!("cancel requested for session {}", args.session_id); Ok(()) } + + async fn set_session_config_option( + &self, + args: SetSessionConfigOptionRequest, + ) -> agent_client_protocol::Result<SetSessionConfigOptionResponse> { + log::info!( + "set_session_config_option: config_id={}, value={:?}", + args.config_id, + args.value + ); + + if args.config_id.0.as_ref() != MODEL_CONFIG_ID { + return Err(agent_client_protocol::Error::new( + -32602, + format!("unknown config option: {}", args.config_id.0), + )); + } + + let model_id = args.value.0.as_ref(); + let _new_config = self.switch_model_by_id(model_id).await?; + + let config_options = { + let guard = self.current_model.lock().unwrap(); + build_model_config_options(&guard) + }; + + log::info!("model switch complete"); + Ok(SetSessionConfigOptionResponse::new(config_options)) + } } // ── Output capture ──────────────────────────────────────────────────────────── @@ -907,6 +1318,7 @@ async fn run_acp_server() -> anyhow::Result<()> { log::info!("ACP startup model: {}", config.display_name); + let startup_config = config.clone(); engine .load_gguf_model(config, Some(SYSTEM_PROMPT.to_string()), Some(sampling)) .await @@ -915,7 +1327,7 @@ async fn run_acp_server() -> anyhow::Result<()> { log::info!("model loaded and ready"); let (notification_tx, mut notification_rx) = mpsc::channel::<SessionNotification>(256); - let agent = SiGitAgent::new(engine, notification_tx); + let agent = SiGitAgent::new(engine, notification_tx, startup_config); // AgentSideConnection wants futures-io, not tokio-io. let stdin = tokio::io::stdin().compat();
src/setup.rs
+51 -30
index 2fce7d8..62960dd 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -132,18 +132,30 @@ pub enum ModelCacheHealth { /// 2. Standard Hugging Face cache pub fn discover_local_models() -> Vec<DiscoveredModel> { let mut models = Vec::new(); + let mut seen_roots = Vec::new(); if let Some(app_group_models) = app_group_models_root() { + seen_roots.push(app_group_models.clone()); collect_models_from_cache_root(&app_group_models, true, &mut models); } - if let Some(hf_cache) = hf_cache_root() { + if let Some(hf_cache) = hf_cache_root() + && !seen_roots.iter().any(|root| root == &hf_cache) + { + seen_roots.push(hf_cache.clone()); collect_models_from_cache_root(&hf_cache, false, &mut models); } + if let Some(default_hf_cache) = default_hf_cache_root() + && !seen_roots.iter().any(|root| root == &default_hf_cache) + { + collect_models_from_cache_root(&default_hf_cache, false, &mut models); + } + models.sort_by(|left, right| { - left.cache_health - .cmp(&right.cache_health) + right + .from_app_group + .cmp(&left.from_app_group) .then_with(|| { left.display_name .to_lowercase() @@ -206,8 +218,6 @@ fn collect_models_from_cache_root( Err(_) => continue, }; - let mut has_config_json = false; - let mut has_tokenizer = false; let mut gguf_files = Vec::new(); for file in files.flatten() { @@ -221,17 +231,6 @@ fn collect_models_from_cache_root( None => continue, }; - if file_name == "config.json" { - has_config_json = true; - } - - if file_name == "tokenizer.json" - || file_name == "tokenizer.model" - || file_name == "tokenizer_config.json" - { - has_tokenizer = true; - } - let extension = file_path .extension() .and_then(|ext| ext.to_str()) @@ -242,22 +241,35 @@ fn collect_models_from_cache_root( } } - let cache_health = if has_config_json && has_tokenizer { - ModelCacheHealth::Complete - } else { - ModelCacheHealth::Incomplete - }; - - for (gguf_file, file_path) in gguf_files { + if gguf_files.is_empty() { + // No GGUF file found — the snapshot exists on disk (e.g. only + // metadata arrived, or the download is still in progress). + // Push a sentinel entry with Incomplete health so the model + // picker can show it as disabled rather than hiding it entirely. models.push(DiscoveredModel { - display_name: display_name_for_model(&model_id, &gguf_file), + display_name: display_name_for_model(&model_id, ""), model_id: model_id.clone(), - gguf_file, + gguf_file: String::new(), snapshot_path: snapshot_path.clone(), - gguf_path: file_path, + // Point at the snapshot directory itself; this path is + // never used for loading because Incomplete models are + // filtered out before any GgufModelConfig is built. + gguf_path: snapshot_path.clone(), from_app_group, - cache_health, + cache_health: ModelCacheHealth::Incomplete, }); + } else { + for (gguf_file, file_path) in gguf_files { + models.push(DiscoveredModel { + display_name: display_name_for_model(&model_id, &gguf_file), + model_id: model_id.clone(), + gguf_file, + snapshot_path: snapshot_path.clone(), + gguf_path: file_path, + from_app_group, + cache_health: ModelCacheHealth::Complete, + }); + } } } } @@ -298,6 +310,10 @@ fn hf_cache_root() -> Option<PathBuf> { } } + None +} + +fn default_hf_cache_root() -> Option<PathBuf> { let home = std::env::var("HOME").ok()?; let path = PathBuf::from(home) .join(".cache") @@ -475,11 +491,16 @@ mod tests { let repo_dir = cache_root.join(format!("models--{}", model_id.replace('/', "--"))); let snapshot_dir = repo_dir.join("snapshots").join(snapshot_name); std::fs::create_dir_all(&snapshot_dir).expect("create snapshot dir"); - std::fs::write(snapshot_dir.join(gguf_file), b"gguf").expect("write gguf"); + // Health is determined solely by the presence of a .gguf file. + // A complete snapshot has one; an incomplete snapshot has none + // (e.g. a partial download where only metadata files arrived). if complete { - std::fs::write(snapshot_dir.join("config.json"), b"{}").expect("write config"); - std::fs::write(snapshot_dir.join("tokenizer.json"), b"{}").expect("write tokenizer"); + std::fs::write(snapshot_dir.join(gguf_file), b"gguf placeholder").expect("write gguf"); + } else { + // Simulate a snapshot directory that exists but has no GGUF yet. + std::fs::write(snapshot_dir.join("config.json"), b"{}") + .expect("write config placeholder"); } snapshot_dir