Show available models for download in picker
- Add "Available" source for supported models not yet downloaded - Allow selecting these models to trigger automatic download - Display download status and update system prompt based on tool support - Refactor model config lookup and max token logic - Update UI to indicate downloadable models and their status
paydii committed
Apr 26, 2026 at 06:57 UTC
cc72ab3979c089e0403e43c74c6d5c3ba28c83be
6 files changed
+222
-58
Cargo.lock
-1
index 49a54f1..be1c485 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3799,7 +3799,6 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "onde"
version = "0.1.8"
-source = "git+https://github.com/ondeinference/onde?branch=development#8321bc566cfbca8ff1d4b71f187f2b007fd98433"
dependencies = [
"anyhow",
"cc",
Cargo.toml
+2
-2
index 21cf7bc..0248387 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -21,8 +21,8 @@ path = "src/main.rs"
agent-client-protocol = { version = "0.10.4", features = ["unstable_session_fork", "unstable_session_additional_directories"] }
# Onde Inference engine (local LLM)
-# onde = { path = "../onde" }
-onde = { git = "https://github.com/ondeinference/onde", branch = "development" }
+onde = { path = "../onde" }
+# onde = { git = "https://github.com/ondeinference/onde", branch = "development" }
# Async runtime
async-trait = "0.1"
src/chat.rs
+28
-6
index fca334d..966ad18 100644
--- a/src/chat.rs
+++ b/src/chat.rs
@@ -422,6 +422,14 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect
.bg(Color::Black)
.add_modifier(Modifier::BOLD),
),
+ ModelSource::Available => (
+ "↓",
+ "Available for download",
+ Style::default()
+ .fg(Color::Blue)
+ .bg(Color::Black)
+ .add_modifier(Modifier::BOLD),
+ ),
ModelSource::Fallback => (
"◎",
"Fallback",
@@ -453,15 +461,17 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect
let health_badge = match item.cache_health {
ModelCacheHealth::Complete => "",
ModelCacheHealth::Incomplete => " ! incomplete cache",
+ ModelCacheHealth::NotDownloaded => " ↓ download",
};
let current_badge = if current { " ← current" } else { "" };
let disabled_badge = match item.cache_health {
- ModelCacheHealth::Complete => "",
+ ModelCacheHealth::Complete | ModelCacheHealth::NotDownloaded => "",
ModelCacheHealth::Incomplete => " (unselectable)",
};
let brand_mark = match item.source {
ModelSource::Onde => "◉",
ModelSource::HuggingFace => "○",
+ ModelSource::Available => "↓",
ModelSource::Fallback => "◎",
};
let source = format!(" [{} {}]", brand_mark, item.source_label);
@@ -478,6 +488,7 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect
match item.source {
ModelSource::Onde => Style::default().fg(Color::Green).bg(Color::Black),
ModelSource::HuggingFace => Style::default().fg(Color::Cyan).bg(Color::Black),
+ ModelSource::Available => Style::default().fg(Color::Blue).bg(Color::Black),
ModelSource::Fallback => Style::default().fg(Color::Yellow).bg(Color::Black),
}
};
@@ -1063,11 +1074,17 @@ async fn exec_slash<B: ratatui::backend::Backend>(
return;
}
+ let loading_msg = if model.cache_health == ModelCacheHealth::NotDownloaded {
+ format!(
+ "Downloading and loading {} ({})… this may take a few minutes.",
+ model.display_name, model.description
+ )
+ } else {
+ format!("Loading {}…", model.display_name)
+ };
+
app.close_model_picker();
- app.messages.push(ChatMessage::system(format!(
- "Loading {}…",
- model.display_name
- )));
+ app.messages.push(ChatMessage::system(loading_msg));
terminal.draw(|frame| render(frame, app)).ok();
let (tx, rx) = mpsc::channel(1);
@@ -1083,8 +1100,13 @@ async fn exec_slash<B: ratatui::backend::Backend>(
// 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 system_prompt = crate::system_prompt_for_model(model.tool_calling);
let update = match engine
- .load_gguf_model(model.config.clone(), None, Some(sampling))
+ .load_gguf_model(
+ model.config.clone(),
+ Some(system_prompt.to_string()),
+ Some(sampling),
+ )
.await
{
Ok(_) => {
src/main.rs
+100
-28
index d9889eb..c8b0db7 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -193,6 +193,27 @@ specific and practical.
Be direct and brief. Write clean, idiomatic code. When debugging, go for the \
root cause, not the symptom. Correct beats clever.";
+/// Slim system prompt for models that do not support tool calling.
+///
+/// These models (e.g. DeepSeek Coder v1) cannot use the agent tools, so
+/// the long tool-oriented instructions in [`SYSTEM_PROMPT`] would waste
+/// context and confuse the model. Keep this short and code-focused.
+const SIMPLE_SYSTEM_PROMPT: &str = "\
+Your name is siGit — a coding assistant. \
+You are helpful, concise, and write clean, idiomatic code. \
+Answer any question the user asks — programming, general knowledge, or casual chat. \
+When debugging, address the root cause, not the symptom. \
+Be direct and brief.";
+
+/// Pick the right system prompt based on whether the model supports tool calling.
+pub(crate) fn system_prompt_for_model(tool_calling: bool) -> &'static str {
+ if tool_calling {
+ SYSTEM_PROMPT
+ } else {
+ SIMPLE_SYSTEM_PROMPT
+ }
+}
+
/// Maximum number of tool-calling rounds before forcing a text response.
const MAX_TOOL_ROUNDS: usize = 10;
@@ -297,12 +318,13 @@ impl SiGitAgent {
&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}"),
- )
- })?;
+ let (new_config, max_tokens, new_tool_calling) = 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})",
@@ -323,7 +345,7 @@ impl SiGitAgent {
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_system_prompt = system_prompt_for_model(new_tool_calling).to_string();
let loader_sampling = sampling;
std::thread::spawn(move || {
@@ -393,17 +415,25 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon
let options: Vec<SessionConfigSelectOption> = items
.iter()
- .filter(|item| item.cache_health == setup::ModelCacheHealth::Complete)
+ .filter(|item| item.cache_health != setup::ModelCacheHealth::Incomplete)
.map(|item| {
- let description = if item.tool_calling {
- format!("{} - tool calling", item.description)
+ let mut desc_parts = Vec::new();
+ if item.tool_calling {
+ desc_parts.push("tool calling".to_string());
+ }
+ desc_parts.push(item.description.clone());
+ if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
+ desc_parts.push("↓ download on select".to_string());
+ }
+ let description = desc_parts.join(" - ");
+ let source_badge = if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
+ " [↓ Onde]"
} else {
- item.description.clone()
- };
- let source_badge = match item.source_label.as_str() {
- "Onde" => " [◉ Onde]",
- "HuggingFace" => " [○ HuggingFace]",
- _ => "",
+ match item.source_label.as_str() {
+ "Onde" => " [◉ Onde]",
+ "HuggingFace" => " [○ HuggingFace]",
+ _ => "",
+ }
};
let name = format!("{}{}", item.display_name, source_badge);
SessionConfigSelectOption::new(
@@ -428,15 +458,17 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon
}
/// Look up the GgufModelConfig for a given model_id value from the picker items.
-fn resolve_model_config(model_id: &str) -> Option<(GgufModelConfig, u64)> {
+///
+/// Returns `(config, max_tokens, tool_calling)`.
+fn resolve_model_config(model_id: &str) -> Option<(GgufModelConfig, u64, bool)> {
let items = models::build_model_picker_items();
items
.into_iter()
.find(|item| {
item.config.model_id == model_id
- && item.cache_health == setup::ModelCacheHealth::Complete
+ && item.cache_health != setup::ModelCacheHealth::Incomplete
})
- .map(|item| (item.config, item.max_tokens))
+ .map(|item| (item.config, item.max_tokens, item.tool_calling))
}
#[derive(Debug, Clone)]
@@ -510,6 +542,7 @@ fn format_models_list(current_model: &GgufModelConfig) -> String {
let health_badge = match item.cache_health {
setup::ModelCacheHealth::Complete => "",
setup::ModelCacheHealth::Incomplete => " ! incomplete cache",
+ setup::ModelCacheHealth::NotDownloaded => " ↓ download on select",
};
let source = match source_key {
"Onde" => " [Onde]",
@@ -599,6 +632,38 @@ async fn exec_slash_acp(
),
)
.await;
+ } else if model.cache_health == setup::ModelCacheHealth::NotDownloaded {
+ agent
+ .send_assistant_message(
+ session_id.clone(),
+ format!(
+ "Downloading and loading {} ({})… this may take a few minutes.",
+ model.display_name, model.description
+ ),
+ )
+ .await;
+
+ match agent.switch_model_by_id(&model.config.model_id).await {
+ Ok(new_config) => {
+ agent
+ .send_assistant_message(
+ session_id,
+ format!(
+ "✓ Downloaded and switched to {}",
+ new_config.display_name
+ ),
+ )
+ .await;
+ }
+ Err(err) => {
+ agent
+ .send_assistant_message(
+ session_id,
+ format!("error downloading model: {}", err.message),
+ )
+ .await;
+ }
+ }
} else {
agent
.send_assistant_message(
@@ -1231,7 +1296,12 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
let (load_tx, load_rx) = std::sync::mpsc::channel::<Result<(), String>>();
let loader_engine = Arc::clone(&engine);
- let system_prompt = SYSTEM_PROMPT.to_string();
+ let tool_calling = models::build_model_picker_items()
+ .iter()
+ .find(|item| item.config.model_id == config.model_id)
+ .map(|item| item.tool_calling)
+ .unwrap_or(false);
+ let system_prompt = system_prompt_for_model(tool_calling).to_string();
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().expect("failed to create loader runtime");
let result =
@@ -1304,13 +1374,14 @@ async fn run_acp_server() -> anyhow::Result<()> {
})
.unwrap_or_else(GgufModelConfig::qwen3_4b);
- let max_tokens = if config.display_name == "Qwen 3 4B (Q4_K_M)"
- || config.display_name == "Qwen 3 8B (Q4_K_M)"
- {
- 4096
- } else {
- 512
- };
+ let acp_tool_calling = models::build_model_picker_items()
+ .iter()
+ .find(|item| item.config.model_id == config.model_id)
+ .map(|item| (item.tool_calling, item.max_tokens))
+ .unwrap_or((true, 4096));
+
+ let max_tokens = acp_tool_calling.1;
+ let tool_calling = acp_tool_calling.0;
let sampling = SamplingConfig {
max_tokens: Some(max_tokens),
@@ -1320,8 +1391,9 @@ async fn run_acp_server() -> anyhow::Result<()> {
log::info!("ACP startup model: {}", config.display_name);
let startup_config = config.clone();
+ let acp_system_prompt = system_prompt_for_model(tool_calling).to_string();
engine
- .load_gguf_model(config, Some(SYSTEM_PROMPT.to_string()), Some(sampling))
+ .load_gguf_model(config, Some(acp_system_prompt), Some(sampling))
.await
.map_err(|error| anyhow::anyhow!("model load failed: {error}"))?;
src/models.rs
+91
-21
index c04b53c..853868a 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -16,6 +16,9 @@ pub(crate) use crate::setup::ModelCacheHealth;
pub(crate) enum ModelSource {
Onde,
HuggingFace,
+ /// Supported model that is not yet downloaded locally. When selected it
+ /// will be downloaded into the Onde app-group cache automatically.
+ Available,
Fallback,
}
@@ -32,32 +35,108 @@ pub(crate) struct ModelPickerItem {
pub(crate) cache_health: ModelCacheHealth,
}
+// ── Model ID → GgufModelConfig mapping ────────────────────────────────────────
+
+/// Map a HuggingFace model ID to the corresponding [`GgufModelConfig`]
+/// constructor. Returns `None` for model IDs that siGit does not know how
+/// to load.
+pub(crate) fn model_id_to_config(model_id: &str) -> Option<GgufModelConfig> {
+ Some(match model_id {
+ "bartowski/Qwen_Qwen3-4B-GGUF" => GgufModelConfig::qwen3_4b(),
+ "bartowski/Qwen_Qwen3-8B-GGUF" => GgufModelConfig::qwen3_8b(),
+ "bartowski/Qwen_Qwen3-1.7B-GGUF" => GgufModelConfig::qwen3_1_7b(),
+ "bartowski/Qwen2.5-3B-Instruct-GGUF" => GgufModelConfig::qwen25_3b(),
+ "bartowski/Qwen2.5-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_1_5b(),
+ "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_3b(),
+ "bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_1_5b(),
+ "TheBloke/deepseek-coder-6.7B-instruct-GGUF" => GgufModelConfig::deepseek_coder_6_7b(),
+ _ => return None,
+ })
+}
+
+/// Whether a model ID supports tool calling (Qwen 3 family).
+fn is_tool_calling(model_id: &str) -> bool {
+ matches!(
+ model_id,
+ "bartowski/Qwen_Qwen3-4B-GGUF"
+ | "bartowski/Qwen_Qwen3-8B-GGUF"
+ | "bartowski/Qwen_Qwen3-1.7B-GGUF"
+ )
+}
+
+/// Max tokens for a given model (tool-calling models need higher budgets
+/// because the `<think>…</think>` block consumes tokens before the real
+/// response).
+fn max_tokens_for(model_id: &str) -> u64 {
+ if is_tool_calling(model_id) { 4096 } else { 512 }
+}
+
// ── Builder ───────────────────────────────────────────────────────────────────
-/// Build the full list of available model picker items from the local cache.
+/// Build the full list of model picker items.
///
/// Items are sourced from:
-/// 1. The Onde app-group model cache (macOS shared container).
-/// 2. The HuggingFace hub cache (`HF_HUB_CACHE` / `HF_HOME` / `~/.cache/huggingface/hub`).
+/// 1. **Locally cached** models in the Onde app-group and HuggingFace caches.
+/// 2. **All supported models** from [`onde::inference::models::SUPPORTED_MODEL_INFO`]
+/// that are not yet downloaded locally — shown as `Available` so the user
+/// can select them to trigger a download into the app-group cache.
///
-/// If no models are discovered at all, a single fallback entry for the
-/// platform-default model is returned so the picker is never empty.
+/// If no models are discovered *and* no supported models are known, a single
+/// fallback entry for the platform-default model is returned so the picker
+/// is never empty.
///
-/// Items are sorted by source priority (Onde first, then HuggingFace, then
-/// Fallback) and then alphabetically by display name within each group.
+/// Items are sorted: Onde first, then HuggingFace, then Available (not
+/// downloaded), then Fallback, and alphabetically within each group.
pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
let mut items = Vec::new();
+ // ── 1. Locally discovered models ─────────────────────────────────────
for discovered in crate::setup::discover_local_models() {
if let Some(item) = discovered_model_to_picker_item(discovered) {
items.push(item);
}
}
+ // ── 2. Supported models not yet downloaded ───────────────────────────
+ //
+ // Walk SUPPORTED_MODEL_INFO and add an entry for every model ID that
+ // does not already appear in the local items list (by model_id).
+ // These entries have `cache_health: NotDownloaded` and
+ // `source: Available`. When the user selects one, `load_gguf_model`
+ // will download the GGUF file from HuggingFace into the app-group
+ // cache automatically.
+ for info in onde::inference::models::SUPPORTED_MODEL_INFO {
+ let already_present = items.iter().any(|item| item.config.model_id == info.id);
+ if already_present {
+ continue;
+ }
+
+ let config = match model_id_to_config(info.id) {
+ Some(config) => config,
+ None => continue,
+ };
+
+ let tool_calling = is_tool_calling(info.id);
+ let max_tokens = max_tokens_for(info.id);
+
+ items.push(ModelPickerItem {
+ display_name: config.display_name.clone(),
+ description: config.approx_memory.clone(),
+ tool_calling,
+ max_tokens,
+ config,
+ source_label: "Onde".to_string(),
+
+ source: ModelSource::Available,
+ cache_health: ModelCacheHealth::NotDownloaded,
+ });
+ }
+
+ // ── 3. Fallback ──────────────────────────────────────────────────────
if items.is_empty() {
let config = GgufModelConfig::platform_default();
- let tool_calling = config.display_name == "Qwen 3 4B (Q4_K_M)";
- let max_tokens = if tool_calling { 4096 } else { 512 };
+ let tool_calling = is_tool_calling(&config.model_id);
+ let max_tokens = max_tokens_for(&config.model_id);
items.push(ModelPickerItem {
display_name: config.display_name.clone(),
@@ -90,19 +169,10 @@ fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPicker
"HuggingFace".to_string()
};
- let config = match model.model_id.as_str() {
- "bartowski/Qwen_Qwen3-4B-GGUF" => GgufModelConfig::qwen3_4b(),
- "bartowski/Qwen_Qwen3-8B-GGUF" => GgufModelConfig::qwen3_8b(),
- "bartowski/Qwen2.5-3B-Instruct-GGUF" => GgufModelConfig::qwen25_3b(),
- "bartowski/Qwen2.5-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_1_5b(),
- "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_3b(),
- "bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_1_5b(),
- _ => return None,
- };
+ let config = model_id_to_config(&model.model_id)?;
- let tool_calling = model.model_id == "bartowski/Qwen_Qwen3-4B-GGUF"
- || model.model_id == "bartowski/Qwen_Qwen3-8B-GGUF";
- let max_tokens = if tool_calling { 4096 } else { 512 };
+ let tool_calling = is_tool_calling(&model.model_id);
+ let max_tokens = max_tokens_for(&model.model_id);
Some(ModelPickerItem {
display_name: config.display_name.clone(),
src/setup.rs
+1
index 62960dd..a26af72 100644
--- a/src/setup.rs
+++ b/src/setup.rs
@@ -123,6 +123,7 @@ pub struct DiscoveredModel {
pub enum ModelCacheHealth {
Complete,
Incomplete,
+ NotDownloaded,
}
/// Return all locally discovered GGUF models.