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(¤t_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