diff --git a/crates/codegen/kigi-shell/src/agent/config.rs b/crates/codegen/kigi-shell/src/agent/config.rs index 09a1d38..382ed3c 100644 --- a/crates/codegen/kigi-shell/src/agent/config.rs +++ b/crates/codegen/kigi-shell/src/agent/config.rs @@ -4229,6 +4229,21 @@ pub fn to_acp_model_info( "agentType".to_string(), serde_json::Value::String(info.agent_type.clone()), ); + // Provider display name for managed `{platform}/{model}` + // entries — lets the client's model picker say which + // connected provider each model belongs to. Absent for + // user-defined `[model.*]` entries (no known platform). + if let Some((platform, _)) = info + .id + .as_deref() + .or(Some(key.as_str())) + .and_then(kigi_models::parse_managed_model_key) + { + map.insert( + "provider".to_string(), + serde_json::Value::String(platform.display_name().to_string()), + ); + } if info.supports_reasoning_effort { map.insert( "supportsReasoningEffort".to_string(), @@ -5925,6 +5940,49 @@ reasoning_effort = "low" "agentType should always be in meta, defaulting to DEFAULT_AGENT_TYPE" ); } + /// Managed `{platform}/{model}` entries stamp `meta.provider` with the + /// platform's display name so the client's model picker can say which + /// connected provider each model belongs to. User-defined `[model.*]` + /// entries (no known platform prefix) stay provider-less. + #[test] + fn acp_model_meta_stamps_provider_for_managed_entries() { + let mut models = IndexMap::new(); + let mut managed = test_model_entry( + "claude-opus-4-8", + "https://api.anthropic.com/v1", + None, + None, + None, + ); + managed.info.id = Some("claude-pro-max/claude-opus-4-8".to_string()); + models.insert("claude-pro-max/claude-opus-4-8".to_string(), managed); + models.insert( + "my-custom".to_string(), + test_model_entry("my-custom", "https://test.api/v1", None, None, None), + ); + + let acp_models = to_acp_model_info(&models); + let claude = acp_models + .get(&acp::ModelId::new(Arc::from( + "claude-pro-max/claude-opus-4-8", + ))) + .unwrap(); + assert_eq!( + claude.meta.as_ref().unwrap()["provider"], + kigi_models::PlatformId::ClaudeProMax.display_name(), + ); + let custom = acp_models + .get(&acp::ModelId::new(Arc::from("my-custom"))) + .unwrap(); + assert!( + custom + .meta + .as_ref() + .is_none_or(|m| m.get("provider").is_none()), + "a user-defined entry carries no provider" + ); + } + #[test] fn acp_model_meta_emits_reasoning_effort_when_supported() { let mut models = IndexMap::new(); diff --git a/crates/codegen/kigi-tui/src/slash/commands/model.rs b/crates/codegen/kigi-tui/src/slash/commands/model.rs index 5ffec7a..8b735eb 100644 --- a/crates/codegen/kigi-tui/src/slash/commands/model.rs +++ b/crates/codegen/kigi-tui/src/slash/commands/model.rs @@ -150,6 +150,9 @@ fn detect_effort_phase(models: &ModelState, args_query: &str) -> Option Vec { let current_id = models.current.as_ref(); let mut items: Vec = Vec::with_capacity(models.available.len()); @@ -172,11 +175,22 @@ fn build_model_items(models: &ModelState) -> Vec { info.name.clone() }; + let provider = info + .meta + .as_ref() + .and_then(|m| m.get("provider")) + .and_then(|v| v.as_str()); + let description = match (info.description.as_deref(), provider) { + (Some(d), _) if !d.is_empty() => d.to_string(), + (_, Some(p)) => p.to_string(), + _ => String::new(), + }; + items.push(ArgItem { display, match_text: info.name.clone(), insert_text, - description: info.description.clone().unwrap_or_default(), + description, }); } items @@ -294,6 +308,37 @@ mod tests { assert_eq!(plain.insert_text, "Kigi 4.5"); } + /// Rows for shell-managed platform models show their provider (stamped + /// `meta.provider`) in the description column, so the picker says which + /// connected provider each model belongs to. A model's own description + /// wins when present; entries without provider meta stay blank. + #[test] + fn model_rows_show_provider_in_description() { + let mut state = ModelState::default(); + + let claude_id = acp::ModelId::new(Arc::from("claude-pro-max/claude-opus-4-8")); + let mut meta = serde_json::Map::new(); + meta.insert( + "provider".into(), + serde_json::Value::String("Claude Pro/Max".into()), + ); + let claude = + acp::ModelInfo::new(claude_id.clone(), "Claude Opus 4.8".to_string()).meta(Some(meta)); + state.available.insert(claude_id, claude); + + let (plain_id, plain_info) = plain_model("my-custom", "My Custom"); + state.available.insert(plain_id, plain_info); + + let items = build_model_items(&state); + let claude_row = items + .iter() + .find(|i| i.match_text == "Claude Opus 4.8") + .unwrap(); + assert_eq!(claude_row.description, "Claude Pro/Max"); + let plain_row = items.iter().find(|i| i.match_text == "My Custom").unwrap(); + assert_eq!(plain_row.description, ""); + } + #[test] fn trailing_space_after_reasoning_model_enters_effort_phase() { let mut state = ModelState::default();