diff --git a/src/crates/interfaces/acp/src/client/session_options.rs b/src/crates/interfaces/acp/src/client/session_options.rs index 6d8b2493cc..b4b79f7e48 100644 --- a/src/crates/interfaces/acp/src/client/session_options.rs +++ b/src/crates/interfaces/acp/src/client/session_options.rs @@ -106,6 +106,8 @@ pub struct AcpSessionOptions { pub struct AcpSessionModelOption { pub id: String, pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_name: Option, #[serde(default)] pub description: Option, } @@ -243,13 +245,41 @@ pub(super) fn model_config_id(config_options: &[SessionConfigOption]) -> Option< } fn model_option_from_model_info(model: &ModelInfo) -> AcpSessionModelOption { + let id = model.model_id.to_string(); AcpSessionModelOption { - id: model.model_id.to_string(), + provider_name: provider_name_from_model_identity(&id, model.description.as_deref()), + id, name: model.name.clone(), description: model.description.clone(), } } +fn provider_name_from_model_identity(id: &str, description: Option<&str>) -> Option { + description + .and_then(provider_prefix) + .or_else(|| provider_prefix(id)) +} + +fn normalized_provider_name(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn provider_prefix(value: &str) -> Option { + let (provider, model) = value.trim().split_once('/')?; + let provider = provider.trim(); + let model = model.trim(); + if provider.is_empty() || model.is_empty() { + None + } else { + Some(provider.to_string()) + } +} + fn model_config_option(config_options: &[SessionConfigOption]) -> Option<&SessionConfigOption> { config_options .iter() @@ -274,19 +304,32 @@ fn select_model_values( let models = match &select.options { SessionConfigSelectOptions::Ungrouped(options) => options .iter() - .map(|option| AcpSessionModelOption { - id: option.value.to_string(), - name: option.name.clone(), - description: option.description.clone(), + .map(|option| { + let id = option.value.to_string(); + AcpSessionModelOption { + provider_name: provider_name_from_model_identity( + &id, + option.description.as_deref(), + ), + id, + name: option.name.clone(), + description: option.description.clone(), + } }) .collect(), SessionConfigSelectOptions::Grouped(groups) => groups .iter() .flat_map(|group| { - group.options.iter().map(|option| AcpSessionModelOption { - id: option.value.to_string(), - name: option.name.clone(), - description: option.description.clone(), + group.options.iter().map(|option| { + let id = option.value.to_string(); + AcpSessionModelOption { + provider_name: normalized_provider_name(&group.name).or_else(|| { + provider_name_from_model_identity(&id, option.description.as_deref()) + }), + id, + name: option.name.clone(), + description: option.description.clone(), + } }) }) .collect(), @@ -313,6 +356,21 @@ mod tests { assert!(options.model_config_id.is_none()); } + #[test] + fn extracts_provider_name_from_native_model_identity() { + let state = SessionModelState::new( + "openai/gpt-5.4", + vec![ModelInfo::new("openai/gpt-5.4", "GPT 5.4")], + ); + + let options = session_options_from_state(Some(&state), &[], None); + + assert_eq!( + options.available_models[0].provider_name.as_deref(), + Some("openai") + ); + } + #[test] fn converts_model_config_option_fallback() { let config = SessionConfigOption::select( @@ -334,6 +392,59 @@ mod tests { assert_eq!(options.available_models[1].id, "smart"); } + #[test] + fn extracts_provider_name_from_model_config_description() { + let config = SessionConfigOption::select( + "model", + "Model", + "openai/gpt-5.4", + vec![ + agent_client_protocol::schema::SessionConfigSelectOption::new( + "openai/gpt-5.4", + "GPT 5.4", + ) + .description("openai/gpt-5.4"), + ], + ) + .category(SessionConfigOptionCategory::Model); + + let options = session_options_from_state(None, &[config], None); + + assert_eq!( + options.available_models[0].provider_name.as_deref(), + Some("openai") + ); + } + + #[test] + fn preserves_model_config_group_name_as_provider_name() { + let config = SessionConfigOption::select( + "model", + "Model", + "openai/gpt-5.4", + vec![ + agent_client_protocol::schema::SessionConfigSelectGroup::new( + "openai", + "OpenAI", + vec![ + agent_client_protocol::schema::SessionConfigSelectOption::new( + "openai/gpt-5.4", + "GPT 5.4", + ), + ], + ), + ], + ) + .category(SessionConfigOptionCategory::Model); + + let options = session_options_from_state(None, &[config], None); + + assert_eq!( + options.available_models[0].provider_name.as_deref(), + Some("OpenAI") + ); + } + #[test] fn includes_context_usage() { let state = SessionModelState::new("gpt-5.4", vec![ModelInfo::new("gpt-5.4", "GPT 5.4")]); diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.scss b/src/web-ui/src/flow_chat/components/ModelSelector.scss index a6a517df90..ef387616e3 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.scss +++ b/src/web-ui/src/flow_chat/components/ModelSelector.scss @@ -352,7 +352,11 @@ } &__option-provider { + font-size: var(--flowchat-font-size-2xs); color: var(--color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } &__option-meta { diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index 3dbf6ad742..fc4c36fd05 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -23,7 +23,7 @@ import { Switch, Tooltip } from '@/component-library'; import { FlowChatStore } from '../store/FlowChatStore'; import { getModelMaxTokens } from '../services/flow-chat-manager/SessionModule'; import { acpClientIdFromAgentType } from '../utils/acpSession'; -import { buildAcpFastModeValue, resolveAcpFastModeState } from '../utils/acpSessionConfig'; +import { buildAcpFastModeValue, getAcpModelProviderName, resolveAcpFastModeState } from '../utils/acpSessionConfig'; import { sessionProjectWorkspacePath } from '../utils/sessionWorkspace'; import { buildContextUsageTooltip, @@ -390,7 +390,7 @@ export const ModelSelector: React.FC = ({ id: model.id, configName: model.name, modelName: model.name, - providerName: acpClientId ? `${acpClientId} ACP` : 'ACP', + providerName: getAcpModelProviderName(model) ?? (acpClientId ? `${acpClientId} ACP` : 'ACP'), provider: 'acp', })); }, [acpClientId, acpOptions, isAcpSession]); @@ -780,7 +780,7 @@ export const ModelSelector: React.FC = ({ const isSelected = currentAcpModelId === model.id; return ( - + + ) : selfManagedCliMissing && hasConfigEntry && selfManagedInstallInfo ? ( + ) : canViewError ? ( - ) : ( + ) : !hasConfigEntry ? ( + ) : ( + null )} @@ -1274,8 +1333,11 @@ const AcpAgentsConfig: React.FC = () => { const hasConfigEntry = Boolean(clientConfig); const effectiveConfig = clientConfig ?? (preset ? defaultConfigForPreset(preset) : undefined); const enabled = effectiveConfig?.enabled ?? true; - const requiresAdapter = Boolean(requirementProbe?.adapter || preset?.id !== 'opencode'); + const requiresAdapter = Boolean( + requirementProbe?.adapter || (preset && !NATIVE_ACP_PRESET_IDS.has(preset.id)) + ); const issueKind = getIssueKind({ probe: requirementProbe, requiresAdapter }); + const selfManagedInstallInfo = selfManagedInstallInfoForPreset(preset); const status = getAgentRowStatus({ configured: hasConfigEntry, enabled, @@ -1300,6 +1362,7 @@ const AcpAgentsConfig: React.FC = () => { enabled, requiresAdapter, issueKind, + selfManagedInstallInfo, status, displayName, description, @@ -1369,9 +1432,13 @@ const AcpAgentsConfig: React.FC = () => { issueKind: row.issueKind, probe: row.requirementProbe, requiresAdapter: row.requiresAdapter, + selfManagedInstallInfo: row.selfManagedInstallInfo, }); const canInstallCli = row.preset && row.status === 'not_installed' && row.issueKind === 'cli_missing' && !SELF_MANAGED_INSTALL_PRESET_IDS.has(row.preset.id); + const selfManagedCliMissing = Boolean(row.selfManagedInstallInfo) + && row.status === 'not_installed' + && (row.issueKind === 'cli_missing' || row.requirementProbe?.tool.installed === false); const canViewError = row.status === 'invalid' || row.status === 'partial' || row.issueKind === 'connection_failed' || row.issueKind === 'permission_denied' @@ -1431,6 +1498,16 @@ const AcpAgentsConfig: React.FC = () => { {t('actions.installCli')} + ) : selfManagedCliMissing && row.selfManagedInstallInfo ? ( + ) : row.status === 'enabled' || row.status === 'ready' ? ( row.clientConfig ? (