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.