@sigitcode-old / sigit / commits / 66ace18
Fork 1

I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.

Group /models by inference mode in ACP clients

/models printed one flat list: fifteen cloud rows that each repeated "siGit Code Cloud" three times, then the on-device models. It now sends one collapsible tool-call card per mode, titled with the mode name, "(active)" on the active one and the selected model. The raw input names the mode ("On-device inference", "Onde Cloud inference"). The usage hint goes out as system status. Cloud rows drop the repeated prefix, description and cloud badge. Row numbers stay the global picker index, so /models N selects what is listed. The TUI picker already groups by mode and is unchanged. Closes #257 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Co-Authored-By: siGit Code v1.7.5-acp <noreply@sigit.si>

Seto Elkahfi committed Oct 26, 2026 at 08:59 UTC 66ace1807c0ae22aafd7beae00afa202a7f59b8b
1 file changed +214 -61
crates/cli/src/main.rs
+214 -61
@@ -5400,74 +5400,135 @@ fn parse_on_off(arg: Option<&str>) -> Option<bool> {
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 + &current_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(
@@ -5636,9 +5697,7 @@ 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(&current_model))
5641 - .ok();
5700 + send_models_list(agent, cx, &session_id, &current_model);
5701 }
5702 SlashCommand::Skills => {
5703 agent
@@ -6811,6 +6870,100 @@ mod tests {
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.