@hej / sigit / commits / cc72ab3

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
@@ -3799,7 +3799,6 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
3799 [[package]]
3800 name = "onde"
3801 version = "0.1.8"
3802 -source = "git+https://github.com/ondeinference/onde?branch=development#8321bc566cfbca8ff1d4b71f187f2b007fd98433"
3802 dependencies = [
3803 "anyhow",
3804 "cc",
Cargo.toml
+2 -2
@@ -21,8 +21,8 @@ path = "src/main.rs"
21 agent-client-protocol = { version = "0.10.4", features = ["unstable_session_fork", "unstable_session_additional_directories"] }
22
23 # Onde Inference engine (local LLM)
24 -# onde = { path = "../onde" }
25 -onde = { git = "https://github.com/ondeinference/onde", branch = "development" }
24 +onde = { path = "../onde" }
25 +# onde = { git = "https://github.com/ondeinference/onde", branch = "development" }
26
27 # Async runtime
28 async-trait = "0.1"
src/chat.rs
+28 -6
@@ -422,6 +422,14 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect
422 .bg(Color::Black)
423 .add_modifier(Modifier::BOLD),
424 ),
425 + ModelSource::Available => (
426 + "↓",
427 + "Available for download",
428 + Style::default()
429 + .fg(Color::Blue)
430 + .bg(Color::Black)
431 + .add_modifier(Modifier::BOLD),
432 + ),
433 ModelSource::Fallback => (
434 "◎",
435 "Fallback",
@@ -453,15 +461,17 @@ fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect
461 let health_badge = match item.cache_health {
462 ModelCacheHealth::Complete => "",
463 ModelCacheHealth::Incomplete => " ! incomplete cache",
464 + ModelCacheHealth::NotDownloaded => " ↓ download",
465 };
466 let current_badge = if current { " ← current" } else { "" };
467 let disabled_badge = match item.cache_health {
459 - ModelCacheHealth::Complete => "",
468 + ModelCacheHealth::Complete | ModelCacheHealth::NotDownloaded => "",
469 ModelCacheHealth::Incomplete => " (unselectable)",
470 };
471 let brand_mark = match item.source {
472 ModelSource::Onde => "◉",
473 ModelSource::HuggingFace => "○",
474 + ModelSource::Available => "↓",
475 ModelSource::Fallback => "◎",
476 };
477 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
488 match item.source {
489 ModelSource::Onde => Style::default().fg(Color::Green).bg(Color::Black),
490 ModelSource::HuggingFace => Style::default().fg(Color::Cyan).bg(Color::Black),
491 + ModelSource::Available => Style::default().fg(Color::Blue).bg(Color::Black),
492 ModelSource::Fallback => Style::default().fg(Color::Yellow).bg(Color::Black),
493 }
494 };
@@ -1063,11 +1074,17 @@ async fn exec_slash<B: ratatui::backend::Backend>(
1074 return;
1075 }
1076
1077 + let loading_msg = if model.cache_health == ModelCacheHealth::NotDownloaded {
1078 + format!(
1079 + "Downloading and loading {} ({})… this may take a few minutes.",
1080 + model.display_name, model.description
1081 + )
1082 + } else {
1083 + format!("Loading {}…", model.display_name)
1084 + };
1085 +
1086 app.close_model_picker();
1067 - app.messages.push(ChatMessage::system(format!(
1068 - "Loading {}…",
1069 - model.display_name
1070 - )));
1087 + app.messages.push(ChatMessage::system(loading_msg));
1088 terminal.draw(|frame| render(frame, app)).ok();
1089
1090 let (tx, rx) = mpsc::channel(1);
@@ -1083,8 +1100,13 @@ async fn exec_slash<B: ratatui::backend::Backend>(
1100 // loading the new one. Calling unload_model() explicitly first
1101 // would create a window where no model is loaded — if a message
1102 // arrived in that gap it would fail with NoModelLoaded.
1103 + let system_prompt = crate::system_prompt_for_model(model.tool_calling);
1104 let update = match engine
1087 - .load_gguf_model(model.config.clone(), None, Some(sampling))
1105 + .load_gguf_model(
1106 + model.config.clone(),
1107 + Some(system_prompt.to_string()),
1108 + Some(sampling),
1109 + )
1110 .await
1111 {
1112 Ok(_) => {
src/main.rs
+100 -28
@@ -193,6 +193,27 @@ specific and practical.
193 Be direct and brief. Write clean, idiomatic code. When debugging, go for the \
194 root cause, not the symptom. Correct beats clever.";
195
196 +/// Slim system prompt for models that do not support tool calling.
197 +///
198 +/// These models (e.g. DeepSeek Coder v1) cannot use the agent tools, so
199 +/// the long tool-oriented instructions in [`SYSTEM_PROMPT`] would waste
200 +/// context and confuse the model. Keep this short and code-focused.
201 +const SIMPLE_SYSTEM_PROMPT: &str = "\
202 +Your name is siGit — a coding assistant. \
203 +You are helpful, concise, and write clean, idiomatic code. \
204 +Answer any question the user asks — programming, general knowledge, or casual chat. \
205 +When debugging, address the root cause, not the symptom. \
206 +Be direct and brief.";
207 +
208 +/// Pick the right system prompt based on whether the model supports tool calling.
209 +pub(crate) fn system_prompt_for_model(tool_calling: bool) -> &'static str {
210 + if tool_calling {
211 + SYSTEM_PROMPT
212 + } else {
213 + SIMPLE_SYSTEM_PROMPT
214 + }
215 +}
216 +
217 /// Maximum number of tool-calling rounds before forcing a text response.
218 const MAX_TOOL_ROUNDS: usize = 10;
219
@@ -297,12 +318,13 @@ impl SiGitAgent {
318 &self,
319 model_id: &str,
320 ) -> agent_client_protocol::Result<GgufModelConfig> {
300 - let (new_config, max_tokens) = resolve_model_config(model_id).ok_or_else(|| {
301 - agent_client_protocol::Error::new(
302 - -32602,
303 - format!("unknown or unavailable model: {model_id}"),
304 - )
305 - })?;
321 + let (new_config, max_tokens, new_tool_calling) = resolve_model_config(model_id)
322 + .ok_or_else(|| {
323 + agent_client_protocol::Error::new(
324 + -32602,
325 + format!("unknown or unavailable model: {model_id}"),
326 + )
327 + })?;
328
329 log::info!(
330 "switching model to {} (max_tokens={max_tokens})",
@@ -323,7 +345,7 @@ impl SiGitAgent {
345 let (result_tx, result_rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
346 let loader_engine = Arc::clone(&self.engine);
347 let loader_config = new_config.clone();
326 - let loader_system_prompt = SYSTEM_PROMPT.to_string();
348 + let loader_system_prompt = system_prompt_for_model(new_tool_calling).to_string();
349 let loader_sampling = sampling;
350
351 std::thread::spawn(move || {
@@ -393,17 +415,25 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon
415
416 let options: Vec<SessionConfigSelectOption> = items
417 .iter()
396 - .filter(|item| item.cache_health == setup::ModelCacheHealth::Complete)
418 + .filter(|item| item.cache_health != setup::ModelCacheHealth::Incomplete)
419 .map(|item| {
398 - let description = if item.tool_calling {
399 - format!("{} - tool calling", item.description)
420 + let mut desc_parts = Vec::new();
421 + if item.tool_calling {
422 + desc_parts.push("tool calling".to_string());
423 + }
424 + desc_parts.push(item.description.clone());
425 + if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
426 + desc_parts.push("↓ download on select".to_string());
427 + }
428 + let description = desc_parts.join(" - ");
429 + let source_badge = if item.cache_health == setup::ModelCacheHealth::NotDownloaded {
430 + " [↓ Onde]"
431 } else {
401 - item.description.clone()
402 - };
403 - let source_badge = match item.source_label.as_str() {
404 - "Onde" => " [◉ Onde]",
405 - "HuggingFace" => " [○ HuggingFace]",
406 - _ => "",
432 + match item.source_label.as_str() {
433 + "Onde" => " [◉ Onde]",
434 + "HuggingFace" => " [○ HuggingFace]",
435 + _ => "",
436 + }
437 };
438 let name = format!("{}{}", item.display_name, source_badge);
439 SessionConfigSelectOption::new(
@@ -428,15 +458,17 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon
458 }
459
460 /// Look up the GgufModelConfig for a given model_id value from the picker items.
431 -fn resolve_model_config(model_id: &str) -> Option<(GgufModelConfig, u64)> {
461 +///
462 +/// Returns `(config, max_tokens, tool_calling)`.
463 +fn resolve_model_config(model_id: &str) -> Option<(GgufModelConfig, u64, bool)> {
464 let items = models::build_model_picker_items();
465 items
466 .into_iter()
467 .find(|item| {
468 item.config.model_id == model_id
437 - && item.cache_health == setup::ModelCacheHealth::Complete
469 + && item.cache_health != setup::ModelCacheHealth::Incomplete
470 })
439 - .map(|item| (item.config, item.max_tokens))
471 + .map(|item| (item.config, item.max_tokens, item.tool_calling))
472 }
473
474 #[derive(Debug, Clone)]
@@ -510,6 +542,7 @@ fn format_models_list(current_model: &GgufModelConfig) -> String {
542 let health_badge = match item.cache_health {
543 setup::ModelCacheHealth::Complete => "",
544 setup::ModelCacheHealth::Incomplete => " ! incomplete cache",
545 + setup::ModelCacheHealth::NotDownloaded => " ↓ download on select",
546 };
547 let source = match source_key {
548 "Onde" => " [Onde]",
@@ -599,6 +632,38 @@ async fn exec_slash_acp(
632 ),
633 )
634 .await;
635 + } else if model.cache_health == setup::ModelCacheHealth::NotDownloaded {
636 + agent
637 + .send_assistant_message(
638 + session_id.clone(),
639 + format!(
640 + "Downloading and loading {} ({})… this may take a few minutes.",
641 + model.display_name, model.description
642 + ),
643 + )
644 + .await;
645 +
646 + match agent.switch_model_by_id(&model.config.model_id).await {
647 + Ok(new_config) => {
648 + agent
649 + .send_assistant_message(
650 + session_id,
651 + format!(
652 + "✓ Downloaded and switched to {}",
653 + new_config.display_name
654 + ),
655 + )
656 + .await;
657 + }
658 + Err(err) => {
659 + agent
660 + .send_assistant_message(
661 + session_id,
662 + format!("error downloading model: {}", err.message),
663 + )
664 + .await;
665 + }
666 + }
667 } else {
668 agent
669 .send_assistant_message(
@@ -1231,7 +1296,12 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
1296 let (load_tx, load_rx) = std::sync::mpsc::channel::<Result<(), String>>();
1297
1298 let loader_engine = Arc::clone(&engine);
1234 - let system_prompt = SYSTEM_PROMPT.to_string();
1299 + let tool_calling = models::build_model_picker_items()
1300 + .iter()
1301 + .find(|item| item.config.model_id == config.model_id)
1302 + .map(|item| item.tool_calling)
1303 + .unwrap_or(false);
1304 + let system_prompt = system_prompt_for_model(tool_calling).to_string();
1305 std::thread::spawn(move || {
1306 let rt = tokio::runtime::Runtime::new().expect("failed to create loader runtime");
1307 let result =
@@ -1304,13 +1374,14 @@ async fn run_acp_server() -> anyhow::Result<()> {
1374 })
1375 .unwrap_or_else(GgufModelConfig::qwen3_4b);
1376
1307 - let max_tokens = if config.display_name == "Qwen 3 4B (Q4_K_M)"
1308 - || config.display_name == "Qwen 3 8B (Q4_K_M)"
1309 - {
1310 - 4096
1311 - } else {
1312 - 512
1313 - };
1377 + let acp_tool_calling = models::build_model_picker_items()
1378 + .iter()
1379 + .find(|item| item.config.model_id == config.model_id)
1380 + .map(|item| (item.tool_calling, item.max_tokens))
1381 + .unwrap_or((true, 4096));
1382 +
1383 + let max_tokens = acp_tool_calling.1;
1384 + let tool_calling = acp_tool_calling.0;
1385
1386 let sampling = SamplingConfig {
1387 max_tokens: Some(max_tokens),
@@ -1320,8 +1391,9 @@ async fn run_acp_server() -> anyhow::Result<()> {
1391 log::info!("ACP startup model: {}", config.display_name);
1392
1393 let startup_config = config.clone();
1394 + let acp_system_prompt = system_prompt_for_model(tool_calling).to_string();
1395 engine
1324 - .load_gguf_model(config, Some(SYSTEM_PROMPT.to_string()), Some(sampling))
1396 + .load_gguf_model(config, Some(acp_system_prompt), Some(sampling))
1397 .await
1398 .map_err(|error| anyhow::anyhow!("model load failed: {error}"))?;
1399
src/models.rs
+91 -21
@@ -16,6 +16,9 @@ pub(crate) use crate::setup::ModelCacheHealth;
16 pub(crate) enum ModelSource {
17 Onde,
18 HuggingFace,
19 + /// Supported model that is not yet downloaded locally. When selected it
20 + /// will be downloaded into the Onde app-group cache automatically.
21 + Available,
22 Fallback,
23 }
24
@@ -32,32 +35,108 @@ pub(crate) struct ModelPickerItem {
35 pub(crate) cache_health: ModelCacheHealth,
36 }
37
38 +// ── Model ID → GgufModelConfig mapping ────────────────────────────────────────
39 +
40 +/// Map a HuggingFace model ID to the corresponding [`GgufModelConfig`]
41 +/// constructor. Returns `None` for model IDs that siGit does not know how
42 +/// to load.
43 +pub(crate) fn model_id_to_config(model_id: &str) -> Option<GgufModelConfig> {
44 + Some(match model_id {
45 + "bartowski/Qwen_Qwen3-4B-GGUF" => GgufModelConfig::qwen3_4b(),
46 + "bartowski/Qwen_Qwen3-8B-GGUF" => GgufModelConfig::qwen3_8b(),
47 + "bartowski/Qwen_Qwen3-1.7B-GGUF" => GgufModelConfig::qwen3_1_7b(),
48 + "bartowski/Qwen2.5-3B-Instruct-GGUF" => GgufModelConfig::qwen25_3b(),
49 + "bartowski/Qwen2.5-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_1_5b(),
50 + "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_3b(),
51 + "bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_1_5b(),
52 + "TheBloke/deepseek-coder-6.7B-instruct-GGUF" => GgufModelConfig::deepseek_coder_6_7b(),
53 + _ => return None,
54 + })
55 +}
56 +
57 +/// Whether a model ID supports tool calling (Qwen 3 family).
58 +fn is_tool_calling(model_id: &str) -> bool {
59 + matches!(
60 + model_id,
61 + "bartowski/Qwen_Qwen3-4B-GGUF"
62 + | "bartowski/Qwen_Qwen3-8B-GGUF"
63 + | "bartowski/Qwen_Qwen3-1.7B-GGUF"
64 + )
65 +}
66 +
67 +/// Max tokens for a given model (tool-calling models need higher budgets
68 +/// because the `<think>…</think>` block consumes tokens before the real
69 +/// response).
70 +fn max_tokens_for(model_id: &str) -> u64 {
71 + if is_tool_calling(model_id) { 4096 } else { 512 }
72 +}
73 +
74 // ── Builder ───────────────────────────────────────────────────────────────────
75
37 -/// Build the full list of available model picker items from the local cache.
76 +/// Build the full list of model picker items.
77 ///
78 /// Items are sourced from:
40 -/// 1. The Onde app-group model cache (macOS shared container).
41 -/// 2. The HuggingFace hub cache (`HF_HUB_CACHE` / `HF_HOME` / `~/.cache/huggingface/hub`).
79 +/// 1. **Locally cached** models in the Onde app-group and HuggingFace caches.
80 +/// 2. **All supported models** from [`onde::inference::models::SUPPORTED_MODEL_INFO`]
81 +/// that are not yet downloaded locally — shown as `Available` so the user
82 +/// can select them to trigger a download into the app-group cache.
83 ///
43 -/// If no models are discovered at all, a single fallback entry for the
44 -/// platform-default model is returned so the picker is never empty.
84 +/// If no models are discovered *and* no supported models are known, a single
85 +/// fallback entry for the platform-default model is returned so the picker
86 +/// is never empty.
87 ///
46 -/// Items are sorted by source priority (Onde first, then HuggingFace, then
47 -/// Fallback) and then alphabetically by display name within each group.
88 +/// Items are sorted: Onde first, then HuggingFace, then Available (not
89 +/// downloaded), then Fallback, and alphabetically within each group.
90 pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> {
91 let mut items = Vec::new();
92
93 + // ── 1. Locally discovered models ─────────────────────────────────────
94 for discovered in crate::setup::discover_local_models() {
95 if let Some(item) = discovered_model_to_picker_item(discovered) {
96 items.push(item);
97 }
98 }
99
100 + // ── 2. Supported models not yet downloaded ───────────────────────────
101 + //
102 + // Walk SUPPORTED_MODEL_INFO and add an entry for every model ID that
103 + // does not already appear in the local items list (by model_id).
104 + // These entries have `cache_health: NotDownloaded` and
105 + // `source: Available`. When the user selects one, `load_gguf_model`
106 + // will download the GGUF file from HuggingFace into the app-group
107 + // cache automatically.
108 + for info in onde::inference::models::SUPPORTED_MODEL_INFO {
109 + let already_present = items.iter().any(|item| item.config.model_id == info.id);
110 + if already_present {
111 + continue;
112 + }
113 +
114 + let config = match model_id_to_config(info.id) {
115 + Some(config) => config,
116 + None => continue,
117 + };
118 +
119 + let tool_calling = is_tool_calling(info.id);
120 + let max_tokens = max_tokens_for(info.id);
121 +
122 + items.push(ModelPickerItem {
123 + display_name: config.display_name.clone(),
124 + description: config.approx_memory.clone(),
125 + tool_calling,
126 + max_tokens,
127 + config,
128 + source_label: "Onde".to_string(),
129 +
130 + source: ModelSource::Available,
131 + cache_health: ModelCacheHealth::NotDownloaded,
132 + });
133 + }
134 +
135 + // ── 3. Fallback ──────────────────────────────────────────────────────
136 if items.is_empty() {
137 let config = GgufModelConfig::platform_default();
59 - let tool_calling = config.display_name == "Qwen 3 4B (Q4_K_M)";
60 - let max_tokens = if tool_calling { 4096 } else { 512 };
138 + let tool_calling = is_tool_calling(&config.model_id);
139 + let max_tokens = max_tokens_for(&config.model_id);
140
141 items.push(ModelPickerItem {
142 display_name: config.display_name.clone(),
@@ -90,19 +169,10 @@ fn discovered_model_to_picker_item(model: DiscoveredModel) -> Option<ModelPicker
169 "HuggingFace".to_string()
170 };
171
93 - let config = match model.model_id.as_str() {
94 - "bartowski/Qwen_Qwen3-4B-GGUF" => GgufModelConfig::qwen3_4b(),
95 - "bartowski/Qwen_Qwen3-8B-GGUF" => GgufModelConfig::qwen3_8b(),
96 - "bartowski/Qwen2.5-3B-Instruct-GGUF" => GgufModelConfig::qwen25_3b(),
97 - "bartowski/Qwen2.5-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_1_5b(),
98 - "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_3b(),
99 - "bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_coder_1_5b(),
100 - _ => return None,
101 - };
172 + let config = model_id_to_config(&model.model_id)?;
173
103 - let tool_calling = model.model_id == "bartowski/Qwen_Qwen3-4B-GGUF"
104 - || model.model_id == "bartowski/Qwen_Qwen3-8B-GGUF";
105 - let max_tokens = if tool_calling { 4096 } else { 512 };
174 + let tool_calling = is_tool_calling(&model.model_id);
175 + let max_tokens = max_tokens_for(&model.model_id);
176
177 Some(ModelPickerItem {
178 display_name: config.display_name.clone(),
src/setup.rs
+1
@@ -123,6 +123,7 @@ pub struct DiscoveredModel {
123 pub enum ModelCacheHealth {
124 Complete,
125 Incomplete,
126 + NotDownloaded,
127 }
128
129 /// Return all locally discovered GGUF models.