5400
}
5401
}
5402
5403
-fn format_models_list(current_model: &GgufModelConfig) -> String {
5403
+/// One inference mode of the `/models` list: a card title, the mode shown as
5404
+/// its raw input, and its rows.
5405
+struct ModelGroup {
5406
+ title: String,
5407
+ description: &'static str,
5408
+ rows: String,
5409
+}
5410
+
5411
+/// Send `/models` as one collapsible tool-call card per inference mode
5412
+/// (clients fold those behind a chevron), then a usage hint as system status.
5413
+fn send_models_list(
5414
+ agent: &SiGitAgent,
5415
+ cx: &ConnectionTo<Client>,
5416
+ session_id: &SessionId,
5417
+ current_model: &GgufModelConfig,
5418
+) {
5419
let items = models::build_model_picker_items();
5420
if items.is_empty() {
5406
- return "No local models found. siGit will use the platform default model.".to_string();
5421
+ agent
5422
+ .send_assistant_message(
5423
+ cx,
5424
+ session_id.clone(),
5425
+ "No local models found. siGit will use the platform default model.",
5426
+ )
5427
+ .ok();
5428
+ return;
5429
}
5430
+ for group in render_models_list(
5431
+ &items,
5432
+ models::active_inference_kind(),
5433
+ ¤t_model.model_id,
5434
+ ) {
5435
+ agent
5436
+ .send_tool_call_update(
5437
+ cx,
5438
+ session_id.clone(),
5439
+ SessionUpdate::ToolCall(
5440
+ ToolCall::new(format!("models-{}", uuid::Uuid::new_v4()), group.title)
5441
+ .kind(ToolKind::Other)
5442
+ .status(ToolCallStatus::Completed)
5443
+ .raw_input(serde_json::Value::String(group.description.to_string()))
5444
+ .content(vec![group.rows.into()]),
5445
+ ),
5446
+ )
5447
+ .ok();
5448
+ }
5449
+ agent
5450
+ .send_system_status(cx, session_id.clone(), "Use /models N to switch models.")
5451
+ .ok();
5452
+}
5453
5409
- let mut lines = vec!["Available models:".to_string()];
5410
- let mut last_source: Option<&str> = None;
5454
+/// Group the picker items by inference mode. Numbers are the global picker
5455
+/// index, since `/models N` indexes the same (kind-sorted) list.
5456
+fn render_models_list(
5457
+ items: &[models::ModelPickerItem],
5458
+ active: models::InferenceKind,
5459
+ current_id: &str,
5460
+) -> Vec<ModelGroup> {
5461
+ let mut groups = Vec::new();
5462
+ let mut start = 0;
5463
+ while start < items.len() {
5464
+ let kind = items[start].source.kind();
5465
+ let end = items[start..]
5466
+ .iter()
5467
+ .position(|item| item.source.kind() != kind)
5468
+ .map_or(items.len(), |offset| start + offset);
5469
+ let group = &items[start..end];
5470
5412
- for (index, item) in items.iter().enumerate() {
5413
- let source_key = match item.source_label.as_str() {
5414
- "Onde" => "Onde",
5415
- "HuggingFace" => "HuggingFace",
5416
- "siGit Code Cloud" => "Cloud",
5417
- _ => "Fallback",
5471
+ let mut title = match kind {
5472
+ models::InferenceKind::Cloud => "siGit Code Cloud".to_string(),
5473
+ models::InferenceKind::Local => "siGit Code on-device".to_string(),
5474
};
5475
+ if kind == active {
5476
+ title.push_str(" (active)");
5477
+ }
5478
+ if let Some(selected) = group.iter().find(|item| item.config.model_id == current_id) {
5479
+ let selected_name = match &selected.cloud_tier {
5480
+ Some(tier) => provider::tier_title(tier),
5481
+ None => selected.display_name.clone(),
5482
+ };
5483
+ title.push_str(&format!(" · {selected_name}"));
5484
+ }
5485
5420
- if last_source != Some(source_key) {
5421
- if last_source.is_some() {
5422
- lines.push(String::new());
5486
+ let mut rows = Vec::new();
5487
+ for (offset, item) in group.iter().enumerate() {
5488
+ let number = start + offset + 1;
5489
+ let current_badge = if item.config.model_id == current_id {
5490
+ " ← current"
5491
+ } else {
5492
+ ""
5493
+ };
5494
+ let tool_badge = if item.tool_calling {
5495
+ " tool calling"
5496
+ } else {
5497
+ ""
5498
+ };
5499
+ if let Some(tier) = &item.cloud_tier {
5500
+ rows.push(format!(
5501
+ "{number}. {}{tool_badge}{current_badge}",
5502
+ provider::tier_title(tier)
5503
+ ));
5504
+ continue;
5505
}
5424
- let section = match source_key {
5425
- "Onde" => "Onde Inference",
5426
- "HuggingFace" => "Hugging Face cache",
5427
- "Cloud" => "siGit Code Cloud",
5428
- _ => "Fallback",
5506
+ let health_badge = match item.cache_health {
5507
+ setup::ModelCacheHealth::Complete => "",
5508
+ setup::ModelCacheHealth::Incomplete => " ! incomplete cache",
5509
+ setup::ModelCacheHealth::NotDownloaded => " ↓ download on select",
5510
};
5430
- lines.push(section.to_string());
5431
- // Blank line so the following "N." items render as an ordered list.
5432
- // CommonMark only lets an ordered list interrupt a paragraph when it
5433
- // starts at 1, so without this the cloud section (items 9+) would be
5434
- // absorbed into the header paragraph.
5435
- lines.push(String::new());
5436
- last_source = Some(source_key);
5437
- }
5438
-
5439
- let number = index + 1;
5440
- let current_badge = if item.config.model_id == current_model.model_id {
5441
- " <- current"
5442
- } else {
5443
- ""
5444
- };
5445
- let tool_badge = if item.tool_calling {
5446
- " tool calling"
5447
- } else {
5448
- ""
5449
- };
5450
- let health_badge = match item.cache_health {
5451
- setup::ModelCacheHealth::Complete => "",
5452
- setup::ModelCacheHealth::Incomplete => " ! incomplete cache",
5453
- setup::ModelCacheHealth::NotDownloaded => " ↓ download on select",
5454
- };
5455
- let source = match source_key {
5456
- "Onde" => " [Onde]",
5457
- "HuggingFace" => " [HuggingFace]",
5458
- "Cloud" => " [☁ Cloud]",
5459
- _ => " [default]",
5460
- };
5461
-
5462
- lines.push(format!(
5463
- "{number}. {} {}{}{}{}{}",
5464
- item.display_name, item.description, tool_badge, health_badge, current_badge, source,
5465
- ));
5511
+ let source = match item.source_label.as_str() {
5512
+ "Onde" => " [Onde]",
5513
+ "HuggingFace" => " [HuggingFace]",
5514
+ _ => " [default]",
5515
+ };
5516
+ rows.push(format!(
5517
+ "{number}. {} {}{tool_badge}{health_badge}{current_badge}{source}",
5518
+ item.display_name, item.description,
5519
+ ));
5520
+ }
5521
+ groups.push(ModelGroup {
5522
+ title,
5523
+ description: match kind {
5524
+ models::InferenceKind::Cloud => "Onde Cloud inference",
5525
+ models::InferenceKind::Local => "On-device inference",
5526
+ },
5527
+ rows: rows.join("\n"),
5528
+ });
5529
+ start = end;
5530
}
5467
-
5468
- lines.push(String::new());
5469
- lines.push("Use /models N to switch models.".to_string());
5470
- lines.join("\n")
5531
+ groups
5532
}
5533
5534
async fn exec_slash_acp(
5697
}
5698
SlashCommand::Models(None) => {
5699
let current_model = agent.current_model.lock().unwrap().clone();
5639
- agent
5640
- .send_assistant_message(cx, session_id, format_models_list(¤t_model))
5641
- .ok();
5700
+ send_models_list(agent, cx, &session_id, ¤t_model);
5701
}
5702
SlashCommand::Skills => {
5703
agent
6870
assert_eq!(actual, expected);
6871
}
6872
6873
+ fn picker_item(
6874
+ config: GgufModelConfig,
6875
+ source: models::ModelSource,
6876
+ cloud_tier: Option<&str>,
6877
+ ) -> models::ModelPickerItem {
6878
+ let cloud = cloud_tier.is_some();
6879
+ models::ModelPickerItem {
6880
+ display_name: match cloud_tier {
6881
+ Some(tier) => format!("siGit Code Cloud · {}", provider::tier_title(tier)),
6882
+ None => config.display_name.clone(),
6883
+ },
6884
+ description: if cloud { "siGit Code Cloud" } else { "~2 GB" }.to_string(),
6885
+ tool_calling: true,
6886
+ max_tokens: 4096,
6887
+ context_window_tokens: 32_768,
6888
+ config,
6889
+ source_label: match source {
6890
+ models::ModelSource::Onde => "Onde",
6891
+ models::ModelSource::Cloud => "siGit Code Cloud",
6892
+ _ => "Available",
6893
+ }
6894
+ .to_string(),
6895
+ source,
6896
+ cache_health: setup::ModelCacheHealth::Complete,
6897
+ cloud_tier: cloud_tier.map(str::to_string),
6898
+ }
6899
+ }
6900
+
6901
+ fn sample_picker(active_first: models::InferenceKind) -> Vec<models::ModelPickerItem> {
6902
+ let cloud = [
6903
+ picker_item(
6904
+ GgufModelConfig::qwen3_14b(),
6905
+ models::ModelSource::Cloud,
6906
+ Some("air"),
6907
+ ),
6908
+ picker_item(
6909
+ GgufModelConfig::qwen3_8b(),
6910
+ models::ModelSource::Cloud,
6911
+ Some("prism"),
6912
+ ),
6913
+ ];
6914
+ let local = [
6915
+ picker_item(GgufModelConfig::qwen3_4b(), models::ModelSource::Onde, None),
6916
+ picker_item(
6917
+ GgufModelConfig::qwen25_3b(),
6918
+ models::ModelSource::Available,
6919
+ None,
6920
+ ),
6921
+ ];
6922
+ match active_first {
6923
+ models::InferenceKind::Cloud => cloud.into_iter().chain(local).collect(),
6924
+ models::InferenceKind::Local => local.into_iter().chain(cloud).collect(),
6925
+ }
6926
+ }
6927
+
6928
+ #[test]
6929
+ fn models_list_groups_by_mode_with_cloud_active() {
6930
+ let items = sample_picker(models::InferenceKind::Cloud);
6931
+ let groups = render_models_list(
6932
+ &items,
6933
+ models::InferenceKind::Cloud,
6934
+ &GgufModelConfig::qwen3_8b().model_id,
6935
+ );
6936
+ assert_eq!(groups.len(), 2);
6937
+ assert_eq!(groups[0].title, "siGit Code Cloud (active) · Prism");
6938
+ assert_eq!(groups[1].title, "siGit Code on-device");
6939
+ let rows = format!("{}\n{}", groups[0].rows, groups[1].rows);
6940
+ for n in 1..=4 {
6941
+ assert!(rows.contains(&format!("{n}. ")), "missing row {n}");
6942
+ }
6943
+ assert!(groups[0].rows.contains("2. Prism tool calling ← current"));
6944
+ assert!(!rows.contains("siGit Code Cloud ·"));
6945
+ assert!(!rows.contains("[☁ Cloud]"));
6946
+ assert!(groups[1].rows.starts_with("3. "));
6947
+ }
6948
+
6949
+ #[test]
6950
+ fn models_list_marks_local_active() {
6951
+ let items = sample_picker(models::InferenceKind::Local);
6952
+ let groups = render_models_list(
6953
+ &items,
6954
+ models::InferenceKind::Local,
6955
+ &GgufModelConfig::qwen3_4b().model_id,
6956
+ );
6957
+ assert_eq!(groups.len(), 2);
6958
+ assert!(
6959
+ groups[0]
6960
+ .title
6961
+ .starts_with("siGit Code on-device (active) · ")
6962
+ );
6963
+ assert_eq!(groups[1].title, "siGit Code Cloud");
6964
+ assert!(groups[0].rows.contains("[Onde]"));
6965
+ }
6966
+
6967
/// On Windows `to_file_path` requires a drive letter, so the test URIs use
6968
/// `C:` and the expected paths mirror that. On Unix the URIs use plain
6969
/// `/tmp`/`/Users` roots.