diff --git a/app/src/ai/acp/launch.rs b/app/src/ai/acp/launch.rs index 32b5718b..2713cb0e 100644 --- a/app/src/ai/acp/launch.rs +++ b/app/src/ai/acp/launch.rs @@ -1,4 +1,7 @@ -use galaxy_acp::{AcpAgentPreset, AcpLaunchConfig, CODEX_ACP_NPM_VERSION, OPENCODE_NPM_VERSION}; +use galaxy_acp::{ + resolve_known_acp_agent, AcpAgentPreset, AcpLaunchConfig, CODEX_ACP_NPM_VERSION, + OPENCODE_NPM_VERSION, +}; use sha2::{Digest as _, Sha256}; use crate::persistence::model::AcpConversationData; @@ -215,9 +218,11 @@ pub(crate) fn resolve_acp_launch( match agent_id.trim().to_ascii_lowercase().as_str() { "codex" => AcpAgentPreset::Codex.resolve_launch_config(), "opencode" => AcpAgentPreset::OpenCode.resolve_launch_config(), - unknown => Err(format!( - "Unknown ACP agent preset {unknown:?}; choose \"codex\" or \"opencode\", or configure a custom ACP executable" - )), + _ => resolve_known_acp_agent(agent_id).map_err(|error| { + format!( + "{error} Configure a custom ACP executable if this client uses a different command." + ) + }), } } diff --git a/app/src/ai/acp/launch_tests.rs b/app/src/ai/acp/launch_tests.rs index cc564866..c4f2ee6e 100644 --- a/app/src/ai/acp/launch_tests.rs +++ b/app/src/ai/acp/launch_tests.rs @@ -4,7 +4,7 @@ use super::*; fn unknown_builtin_agent_ids_are_rejected() { let error = resolve_acp_launch("mystery-agent", "", &[]).unwrap_err(); - assert!(error.contains("Unknown ACP agent preset")); + assert!(error.contains("Unknown ACP agent")); } #[test] diff --git a/app/src/ai/llms.rs b/app/src/ai/llms.rs index 2a0d25b1..d1e10a10 100644 --- a/app/src/ai/llms.rs +++ b/app/src/ai/llms.rs @@ -509,7 +509,7 @@ fn default_computer_use_llms() -> AvailableLLMs { }, description: None, disable_reason: None, - vision_supported: true, + vision_supported: false, spec: None, provider: LLMProvider::Unknown, host_configs: HashMap::new(), @@ -1164,7 +1164,7 @@ impl LLMPreferences { }, description: Some(provider_name.clone()), disable_reason: None, - vision_supported: model.vision_supported, + vision_supported: model.effective_vision_supported(), spec: None, provider: LLMProvider::LiteLLM, host_configs: HashMap::from([( @@ -1670,13 +1670,14 @@ impl LLMPreferences { .map(|model| OpenAIModelConfig { model_id: model.id, display_name: model.display_name, - vision_supported: false, + vision_supported: true, context_size: model.context_size.unwrap_or(128_000), max_input_tokens: model.context_size, max_output_tokens: None, provider: None, use_rig: true, supports_system_messages: Some(true), + capability_overrides: std::collections::HashMap::new(), reasoning_efforts: Vec::new(), enabled: true, }) @@ -2594,7 +2595,7 @@ async fn fetch_from_litellm_model_info( .and_then(|v| u32::try_from(v).ok()); let context_size = max_input_tokens.unwrap_or(200_000); - let vision_supported = model_info["supports_vision"].as_bool().unwrap_or(false); + let vision_supported = model_info["supports_vision"].as_bool().unwrap_or(true); let display_name = model_name.replace(['-', '_'], " "); let display_name = display_name @@ -2651,6 +2652,7 @@ async fn fetch_from_litellm_model_info( } else { model_info["supports_system_messages"].as_bool() }, + capability_overrides: std::collections::HashMap::new(), reasoning_efforts: Vec::new(), enabled: true, }) @@ -2769,7 +2771,7 @@ async fn fetch_from_openai_models( vision_supported: m["supports_vision"] .as_bool() .or_else(|| m["vision_support"].as_bool()) - .unwrap_or(false), + .unwrap_or(true), context_size, max_input_tokens, max_output_tokens, @@ -2780,6 +2782,7 @@ async fn fetch_from_openai_models( } else { m["supports_system_messages"].as_bool() }, + capability_overrides: std::collections::HashMap::new(), reasoning_efforts: Vec::new(), enabled: true, }) diff --git a/app/src/ai/llms_tests.rs b/app/src/ai/llms_tests.rs index f4bd2ce7..89fa9d98 100644 --- a/app/src/ai/llms_tests.rs +++ b/app/src/ai/llms_tests.rs @@ -152,6 +152,7 @@ fn openai_model(model_id: &str) -> OpenAIModelConfig { provider: None, use_rig: false, supports_system_messages: None, + capability_overrides: std::collections::HashMap::new(), reasoning_efforts: Vec::new(), enabled: true, } diff --git a/app/src/editor/view/element.rs b/app/src/editor/view/element.rs index 88b3f146..a81380ba 100644 --- a/app/src/editor/view/element.rs +++ b/app/src/editor/view/element.rs @@ -1656,7 +1656,7 @@ impl Element for EditorElement { ); } if size.x().is_infinite() { - unimplemented!("we don't yet handle an infinite width constraint on buffer elements"); + size.set_x(0.0); } let top_section_height_lines = top_section_height_px / view_snapshot.line_height; diff --git a/app/src/settings/ai.rs b/app/src/settings/ai.rs index bf223a0c..286eea03 100644 --- a/app/src/settings/ai.rs +++ b/app/src/settings/ai.rs @@ -893,6 +893,13 @@ pub struct OpenAIModelConfig { description = "Whether this endpoint accepts system-role messages. Set false for ChatGPT-backed LiteLLM models that reject them." )] pub supports_system_messages: Option, + /// Per-model capability overrides. Missing entries mean Auto: use provider + /// metadata when available and allow the request path to determine support. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + #[schemars( + description = "Optional per-capability overrides: auto, supported, or unsupported." + )] + pub capability_overrides: HashMap, #[serde(default)] #[schemars( description = "Reasoning effort modes supported by this model when using the ChatGPT subscription provider." @@ -906,11 +913,62 @@ pub struct OpenAIModelConfig { impl settings_value::SettingsValue for OpenAIModelConfig {} impl OpenAIModelConfig { + pub fn capability_override(&self, capability: &str) -> ModelCapabilityOverride { + self.capability_overrides + .get(capability) + .copied() + .unwrap_or_default() + } + + pub fn effective_vision_supported(&self) -> bool { + match self.capability_override("vision") { + ModelCapabilityOverride::Auto => self.vision_supported, + ModelCapabilityOverride::Supported => true, + ModelCapabilityOverride::Unsupported => false, + } + } + pub fn supports_system_messages(&self) -> bool { + if self.capability_override("system_messages") == ModelCapabilityOverride::Unsupported { + return false; + } if self.model_id.starts_with("codex-gpt-") { return false; } - self.supports_system_messages.unwrap_or(true) + match self.capability_override("system_messages") { + ModelCapabilityOverride::Supported => true, + ModelCapabilityOverride::Auto => self.supports_system_messages.unwrap_or(true), + ModelCapabilityOverride::Unsupported => false, + } + } +} + +#[derive( + Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum ModelCapabilityOverride { + #[default] + Auto, + Supported, + Unsupported, +} + +impl ModelCapabilityOverride { + pub fn next(self) -> Self { + match self { + Self::Auto => Self::Supported, + Self::Supported => Self::Unsupported, + Self::Unsupported => Self::Auto, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Auto => "Auto", + Self::Supported => "On", + Self::Unsupported => "Off", + } } } @@ -1013,13 +1071,18 @@ fn default_chatgpt_models() -> Vec { |(model_id, display_name, reasoning_efforts)| OpenAIModelConfig { model_id: model_id.to_string(), display_name: display_name.to_string(), - vision_supported: false, + // ChatGPT's subscription backend accepts image input for its chat + // models, but it does not expose a public capability discovery + // endpoint. Keep this explicit catalog in sync with that contract + // so the model picker does not hide vision context. + vision_supported: true, context_size: default_context_size(), max_input_tokens: None, max_output_tokens: None, provider: Some("openai".to_string()), use_rig: true, supports_system_messages: Some(true), + capability_overrides: HashMap::new(), reasoning_efforts: reasoning_efforts.into_iter().map(str::to_string).collect(), enabled: true, }, @@ -1055,13 +1118,16 @@ fn default_openai_providers() -> Vec { models: vec![OpenAIModelConfig { model_id: INITIAL_RIG_MODEL_ID.to_string(), display_name: "Codex GPT-5.6 SOL (xhigh)".to_string(), - vision_supported: false, + // Auto capability detection is optimistic for modern + // multimodal-compatible endpoints; users can override it per model. + vision_supported: true, context_size: default_context_size(), max_input_tokens: None, max_output_tokens: None, provider: Some("openai".to_string()), use_rig: true, supports_system_messages: Some(false), + capability_overrides: HashMap::new(), reasoning_efforts: Vec::new(), enabled: true, }], diff --git a/app/src/settings_view/ai_page.rs b/app/src/settings_view/ai_page.rs index 050e3187..e9b32c0a 100644 --- a/app/src/settings_view/ai_page.rs +++ b/app/src/settings_view/ai_page.rs @@ -707,13 +707,19 @@ impl AISettingsPageView { draft: &AcpProviderDraft, ctx: &mut ViewContext, ) { - let Ok(config) = crate::ai::acp::AcpRuntimeModel::discovery_config_for_values( + let config = match crate::ai::acp::AcpRuntimeModel::discovery_config_for_values( &draft.agent_id, &draft.command, &draft.args, - ) else { - log::warn!("Could not resolve ACP launch configuration for discovery"); - return; + ) { + Ok(config) => config, + Err(error) => { + log::warn!("Could not resolve ACP launch configuration for discovery: {error}"); + self.provider_setup_modal_body.update(ctx, |body, ctx| { + body.finish_acp_discovery(Err(error), Vec::new(), ctx); + }); + return; + } }; self.start_acp_discovery(config, draft.agent_id.clone(), ctx); } @@ -739,7 +745,7 @@ impl AISettingsPageView { log::warn!("Could not start ACP discovery: {error}"); let error_text = error.to_string(); provider_setup_modal_body.update(ctx, |body, ctx| { - body.finish_acp_discovery(Err(error_text), ctx); + body.finish_acp_discovery(Err(error_text), Vec::new(), ctx); }); return; } @@ -751,6 +757,10 @@ impl AISettingsPageView { match result { Ok(options) => { let option_count = options.len(); + let config_options = + crate::ai::acp::AcpRuntimeModel::normalize_config_options( + options.clone(), + ); AISettings::handle(ctx).update(ctx, |settings, ctx| { if let Err(error) = crate::ai::acp::AcpRuntimeModel::upsert_agent_settings( @@ -774,7 +784,7 @@ impl AISettingsPageView { runtime.finish_discovery_success(option_count, ctx); }); provider_setup_modal_body.update(ctx, |body, ctx| { - body.finish_acp_discovery(Ok(()), ctx); + body.finish_acp_discovery(Ok(()), config_options, ctx); }); } Err(error) => { @@ -796,7 +806,7 @@ impl AISettingsPageView { runtime.finish_discovery_failure(error_text.clone(), ctx); }); provider_setup_modal_body.update(ctx, |body, ctx| { - body.finish_acp_discovery(Err(error_text), ctx); + body.finish_acp_discovery(Err(error_text), Vec::new(), ctx); }); } } @@ -1791,12 +1801,12 @@ impl AISettingsPageView { ctx, ) .with_modal_style(UiComponentStyles { - width: Some(640.), - height: Some(600.), + width: Some(900.), + height: Some(700.), ..Default::default() }) .with_body_style(UiComponentStyles { - height: Some(530.), + height: Some(630.), ..Default::default() }) .with_dismiss_on_click() @@ -2017,6 +2027,13 @@ impl AISettingsPageView { agent_id: settings.acp_agent_id.value().clone(), command: settings.acp_agent_command.value().clone(), args: settings.acp_agent_args.value().clone(), + config_options: settings + .acp_agents + .value() + .iter() + .find(|agent| agent.id.eq_ignore_ascii_case(settings.acp_agent_id.value())) + .map(|agent| agent.config_options.clone()) + .unwrap_or_default(), }; let body = self .provider_setup_modal_state diff --git a/app/src/settings_view/provider_setup_modal.rs b/app/src/settings_view/provider_setup_modal.rs index 7e1736c7..ece6242a 100644 --- a/app/src/settings_view/provider_setup_modal.rs +++ b/app/src/settings_view/provider_setup_modal.rs @@ -5,6 +5,7 @@ use galaxyui::elements::{ MouseStateHandle, Padding, ParentElement, Radius, ScrollbarWidth, Text, }; use galaxyui::fonts::{Properties, Weight}; +use galaxyui::text_layout::ClipConfig; use galaxyui::ui_components::button::ButtonVariant; use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; use galaxyui::ui_components::switch::SwitchStateHandle; @@ -21,17 +22,17 @@ use crate::editor::{ }; use crate::modal::{Modal, ModalViewState}; use crate::settings::ai::{ - BedrockAuthMethod, BedrockModelConfig, OpenAIModelConfig, OpenAIProviderConfig, - OpenAIProviderKind, + AcpConfigOptionSettings, BedrockAuthMethod, BedrockModelConfig, ModelCapabilityOverride, + OpenAIModelConfig, OpenAIProviderConfig, OpenAIProviderKind, }; use crate::ui_components::icons::Icon; use crate::view_components::action_button::{ - ActionButton, NakedTheme, PrimaryTheme, SecondaryTheme, + ActionButton, ButtonSize, NakedTheme, PrimaryTheme, SecondaryTheme, }; -const MODAL_WIDTH: f32 = 640.; -const MODAL_HEIGHT: f32 = 600.; -const BODY_HEIGHT: f32 = 530.; +const MODAL_WIDTH: f32 = 900.; +const MODAL_HEIGHT: f32 = 700.; +const BODY_HEIGHT: f32 = 630.; const INPUT_FONT_SIZE: f32 = 12.; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -111,6 +112,7 @@ pub struct AcpProviderDraft { pub agent_id: String, pub command: String, pub args: Vec, + pub config_options: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -120,6 +122,45 @@ enum DiscoveryState { Failed(String), } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CapabilityKey { + Vision, + Files, + Audio, + Tools, + SystemMessages, +} + +impl CapabilityKey { + const ALL: [Self; 5] = [ + Self::Vision, + Self::Files, + Self::Audio, + Self::Tools, + Self::SystemMessages, + ]; + + fn label(self) -> &'static str { + match self { + Self::Vision => "Images", + Self::Files => "Files", + Self::Audio => "Audio", + Self::Tools => "Tools", + Self::SystemMessages => "System", + } + } + + fn setting_key(self) -> &'static str { + match self { + Self::Vision => "vision", + Self::Files => "files", + Self::Audio => "audio", + Self::Tools => "tools", + Self::SystemMessages => "system_messages", + } + } +} + pub enum ProviderSetupModalBodyEvent { Close, RequestAcpDiscovery(AcpProviderDraft), @@ -138,12 +179,14 @@ pub enum ProviderSetupModalBodyAction { Back, Cancel, ToggleModel(usize), + CycleModelCapability(usize, CapabilityKey), ConnectChatGPT, OpenChatGPTDevicePage, CopyChatGPTDeviceCode, SelectBedrockAuth(BedrockAuthMethod), ToggleBedrockCrossRegion, ToggleBedrockAutoLogin, + SelectAcpAgent(String), } pub type ProviderSetupModalState = ModalViewState>; @@ -162,6 +205,7 @@ pub struct ProviderSetupModalBody { draft_acp: AcpProviderDraft, discovery_state: DiscoveryState, provider_type_buttons: Vec>, + acp_agent_buttons: Vec>, name_editor: ViewHandle, base_url_editor: ViewHandle, api_key_editor: ViewHandle, @@ -175,10 +219,16 @@ pub struct ProviderSetupModalBody { acp_agent_id_editor: ViewHandle, acp_command_editor: ViewHandle, acp_args_editor: ViewHandle, + chatgpt_connect_mouse_state: MouseStateHandle, + chatgpt_open_mouse_state: MouseStateHandle, + chatgpt_copy_mouse_state: MouseStateHandle, bedrock_auth_buttons: Vec>, bedrock_cross_region_toggle: SwitchStateHandle, bedrock_auto_login_toggle: SwitchStateHandle, model_switches: Vec, + model_capability_switches: Vec<[SwitchStateHandle; 2]>, + model_capability_buttons: Vec>>, + model_context_editors: Vec>, provider_type_scroll_state: ClippedScrollStateHandle, models_scroll_state: ClippedScrollStateHandle, back_button: ViewHandle, @@ -219,6 +269,34 @@ impl ProviderSetupModalBody { let acp_command_editor = Self::create_editor("Optional executable", false, ctx); let acp_args_editor = Self::create_editor(r#"["arg1", "arg2"]"#, false, ctx); + let mut acp_agent_buttons = galaxy_acp::known_acp_agents() + .iter() + .map(|agent| { + let id = agent.id.to_owned(); + ctx.add_typed_action_view(move |_| { + ActionButton::new(agent.name, NakedTheme) + .with_full_width(true) + .on_click({ + let id = id.clone(); + move |ctx| { + ctx.dispatch_typed_action( + ProviderSetupModalBodyAction::SelectAcpAgent(id.clone()), + ); + } + }) + }) + }) + .collect::>(); + acp_agent_buttons.push(ctx.add_typed_action_view(|_| { + ActionButton::new("Custom", NakedTheme) + .with_full_width(true) + .on_click(|ctx| { + ctx.dispatch_typed_action(ProviderSetupModalBodyAction::SelectAcpAgent( + "custom".to_owned(), + )); + }) + })); + let bedrock_auth_buttons = [ BedrockAuthMethod::Profile, BedrockAuthMethod::Sso, @@ -358,9 +436,11 @@ impl ProviderSetupModalBody { agent_id: "codex".to_string(), command: String::new(), args: Vec::new(), + config_options: Vec::new(), }, discovery_state: DiscoveryState::Idle, provider_type_buttons, + acp_agent_buttons, name_editor, base_url_editor, api_key_editor, @@ -374,10 +454,16 @@ impl ProviderSetupModalBody { acp_agent_id_editor, acp_command_editor, acp_args_editor, + chatgpt_connect_mouse_state: MouseStateHandle::default(), + chatgpt_open_mouse_state: MouseStateHandle::default(), + chatgpt_copy_mouse_state: MouseStateHandle::default(), bedrock_auth_buttons, bedrock_cross_region_toggle: SwitchStateHandle::default(), bedrock_auto_login_toggle: SwitchStateHandle::default(), model_switches: Vec::new(), + model_capability_switches: Vec::new(), + model_capability_buttons: Vec::new(), + model_context_editors: Vec::new(), provider_type_scroll_state: ClippedScrollStateHandle::default(), models_scroll_state: ClippedScrollStateHandle::default(), back_button, @@ -440,10 +526,12 @@ impl ProviderSetupModalBody { agent_id: "codex".to_string(), command: String::new(), args: Vec::new(), + config_options: Vec::new(), }; self.discovery_state = DiscoveryState::Idle; self.sync_editors(ctx); self.sync_provider_type_buttons(ctx); + self.sync_acp_agent_buttons(ctx); self.sync_bedrock_auth_buttons(ctx); self.sync_model_switches(ctx); self.update_next_button(ctx); @@ -457,7 +545,9 @@ impl ProviderSetupModalBody { provider: OpenAIProviderConfig, ctx: &mut ViewContext, ) { - self.step = ProviderSetupStep::Configure; + // Editing an existing provider is a local catalog operation. Do not + // send the user through credentials or model discovery again. + self.step = ProviderSetupStep::Models; self.editing_index = Some(editing_index); self.provider_type = match provider.kind { OpenAIProviderKind::ChatGPTSubscription => { @@ -477,6 +567,7 @@ impl ProviderSetupModalBody { self.discovery_state = DiscoveryState::Idle; self.sync_editors(ctx); self.sync_provider_type_buttons(ctx); + self.sync_acp_agent_buttons(ctx); self.sync_bedrock_auth_buttons(ctx); self.sync_model_switches(ctx); self.update_next_button(ctx); @@ -502,7 +593,11 @@ impl ProviderSetupModalBody { } pub fn begin_edit_acp(&mut self, draft: AcpProviderDraft, ctx: &mut ViewContext) { - self.step = ProviderSetupStep::Configure; + self.step = if draft.config_options.is_empty() { + ProviderSetupStep::Configure + } else { + ProviderSetupStep::Models + }; self.editing_index = None; self.provider_type = ProviderSetupProviderType::Acp; self.draft_name = draft.name.clone(); @@ -521,10 +616,12 @@ impl ProviderSetupModalBody { pub fn finish_acp_discovery( &mut self, result: Result<(), String>, + config_options: Vec, ctx: &mut ViewContext, ) { match result { Ok(()) => { + self.draft_acp.config_options = config_options; self.discovery_state = DiscoveryState::Idle; self.step = ProviderSetupStep::Models; ctx.focus(&self.name_editor); @@ -593,6 +690,23 @@ impl ProviderSetupModalBody { } } + fn sync_acp_agent_buttons(&self, ctx: &mut ViewContext) { + let selected = self.draft_acp.agent_id.trim(); + for (agent, button) in galaxy_acp::known_acp_agents() + .iter() + .zip(self.acp_agent_buttons.iter()) + { + button.update(ctx, |button, ctx| { + button.set_active(agent.id.eq_ignore_ascii_case(selected), ctx); + }); + } + if let Some(button) = self.acp_agent_buttons.last() { + button.update(ctx, |button, ctx| { + button.set_active(selected.eq_ignore_ascii_case("custom"), ctx); + }); + } + } + fn sync_bedrock_auth_buttons(&self, ctx: &mut ViewContext) { for (index, button) in self.bedrock_auth_buttons.iter().enumerate() { let method = match index { @@ -612,6 +726,68 @@ impl ProviderSetupModalBody { self.model_switches.push(SwitchStateHandle::default()); } self.model_switches.truncate(self.draft_models.len()); + + while self.model_capability_switches.len() < self.draft_models.len() { + self.model_capability_switches + .push([SwitchStateHandle::default(), SwitchStateHandle::default()]); + } + self.model_capability_switches + .truncate(self.draft_models.len()); + + while self.model_capability_buttons.len() < self.draft_models.len() { + let index = self.model_capability_buttons.len(); + let buttons = CapabilityKey::ALL + .into_iter() + .map(|key| { + ctx.add_typed_action_view(move |_| { + ActionButton::new(format!("{}: Auto", key.label()), NakedTheme) + .with_size(ButtonSize::XSmall) + .on_click(move |ctx| { + ctx.dispatch_typed_action( + ProviderSetupModalBodyAction::CycleModelCapability(index, key), + ); + }) + }) + }) + .collect(); + self.model_capability_buttons.push(buttons); + } + self.model_capability_buttons + .truncate(self.draft_models.len()); + + while self.model_context_editors.len() < self.draft_models.len() { + let index = self.model_context_editors.len(); + let editor = Self::create_editor("Context window", false, ctx); + ctx.subscribe_to_view(&editor, move |me, editor, event, ctx| { + if matches!(event, EditorEvent::Edited(_)) { + if let Some(model) = me.draft_models.get_mut(index) { + if let Ok(context_size) = editor.as_ref(ctx).buffer_text(ctx).parse() { + model.context_size = context_size; + model.max_input_tokens = Some(context_size); + } + } + me.update_next_button(ctx); + ctx.notify(); + } + }); + self.model_context_editors.push(editor); + } + self.model_context_editors.truncate(self.draft_models.len()); + + for (index, model) in self.draft_models.iter().enumerate() { + for (button, key) in self.model_capability_buttons[index] + .iter() + .zip(CapabilityKey::ALL) + { + let state = model.capability_override(key.setting_key()); + button.update(ctx, |button, ctx| { + button.set_label(format!("{}: {}", key.label(), state.label()), ctx); + }); + } + self.model_context_editors[index].update(ctx, |editor, ctx| { + editor.system_reset_buffer_text(&model.context_size.to_string(), ctx); + }); + } } fn update_next_button(&self, ctx: &mut ViewContext) { @@ -628,7 +804,11 @@ impl ProviderSetupModalBody { .is_none_or(|key| key.trim().is_empty()) } ProviderSetupProviderType::VertexAI => self.draft_project_id.trim().is_empty(), - ProviderSetupProviderType::Acp => self.draft_acp.agent_id.trim().is_empty(), + ProviderSetupProviderType::Acp => { + self.draft_acp.agent_id.trim().is_empty() + || (self.draft_acp.agent_id.eq_ignore_ascii_case("custom") + && self.draft_acp.command.trim().is_empty()) + } ProviderSetupProviderType::ChatGPTSubscription | ProviderSetupProviderType::Bedrock => false, }; @@ -957,6 +1137,14 @@ impl ProviderSetupModalBody { .soft_wrap(true) .finish(), ); + if let ChatGPTAuthState::Failed(error) = &state { + children.push( + Text::new(error.clone(), appearance.monospace_font_family(), 11.) + .with_color(appearance.theme().ui_error_color().into()) + .soft_wrap(true) + .finish(), + ); + } if let ChatGPTAuthState::AwaitingDeviceCode { verification_uri, @@ -985,7 +1173,10 @@ impl ProviderSetupModalBody { .with_child( appearance .ui_builder() - .button(ButtonVariant::Secondary, MouseStateHandle::default()) + .button( + ButtonVariant::Secondary, + self.chatgpt_open_mouse_state.clone(), + ) .with_text_label("Open sign-in page".to_owned()) .build() .on_click(|ctx, _, _| { @@ -998,7 +1189,10 @@ impl ProviderSetupModalBody { .with_child( appearance .ui_builder() - .button(ButtonVariant::Secondary, MouseStateHandle::default()) + .button( + ButtonVariant::Secondary, + self.chatgpt_copy_mouse_state.clone(), + ) .with_text_label("Copy code".to_owned()) .build() .on_click(|ctx, _, _| { @@ -1029,7 +1223,10 @@ impl ProviderSetupModalBody { children.push( appearance .ui_builder() - .button(ButtonVariant::Secondary, MouseStateHandle::default()) + .button( + ButtonVariant::Secondary, + self.chatgpt_connect_mouse_state.clone(), + ) .with_text_label("Connect ChatGPT".to_owned()) .build() .on_click(|ctx, _, _| { @@ -1216,24 +1413,32 @@ impl ProviderSetupModalBody { ); } ProviderSetupProviderType::Acp => { - children.push(self.render_input( - appearance, - "Agent preset", - &self.acp_agent_id_editor, - )); - children.push(self.render_input( - appearance, - "Custom executable (optional)", - &self.acp_command_editor, - )); - children.push(self.render_input( - appearance, - "Arguments (JSON array)", - &self.acp_args_editor, - )); + children.push( + Text::new("ACP client", appearance.ui_font_family(), INPUT_FONT_SIZE) + .with_color(appearance.theme().active_ui_text_color().into()) + .with_style(Properties::default().weight(Weight::Bold)) + .finish(), + ); + children.extend( + self.acp_agent_buttons + .iter() + .map(|button| ChildView::new(button).finish()), + ); + if self.draft_acp.agent_id.eq_ignore_ascii_case("custom") { + children.push(self.render_input( + appearance, + "Executable", + &self.acp_command_editor, + )); + children.push(self.render_input( + appearance, + "Arguments (JSON array)", + &self.acp_args_editor, + )); + } children.push( Text::new( - "ACP agents own their model and authentication. Galaxy will discover the configured runtime before saving.", + "Known clients use their local executable. If the client is not installed, Galaxy will show a launch error. Choose Custom for another ACP-compatible command.", appearance.ui_font_family(), INPUT_FONT_SIZE, ) @@ -1330,7 +1535,150 @@ impl ProviderSetupModalBody { .finish(), ) .with_width(MODAL_WIDTH - 56.) - .with_max_height(320.) + .with_max_height(430.) + .finish() + } + + fn render_model_table_header(&self, appearance: &Appearance) -> Box { + let header = |label: &str| { + Text::new(label.to_owned(), appearance.ui_font_family(), 11.) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .with_style(Properties::default().weight(Weight::Semibold)) + .finish() + }; + Container::new( + Flex::row() + .with_spacing(12.) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(ConstrainedBox::new(header("Use")).with_width(52.).finish()) + .with_child( + ConstrainedBox::new(header("Model")) + .with_width(250.) + .finish(), + ) + .with_child( + ConstrainedBox::new(header("Context")) + .with_width(140.) + .finish(), + ) + .with_child( + ConstrainedBox::new(header("Capabilities")) + .with_width(330.) + .finish(), + ) + .finish(), + ) + .with_padding(Padding::uniform(10.).with_vertical(9.)) + .with_background(appearance.theme().surface_2()) + .with_border(Border::bottom(1.).with_border_fill(appearance.theme().outline())) + .finish() + } + + fn render_model_capabilities( + &self, + appearance: &Appearance, + index: usize, + ) -> Box { + let buttons = &self.model_capability_buttons[index]; + let first_row = buttons[..3] + .iter() + .map(|button| ChildView::new(button).finish()) + .collect::>(); + let second_row = buttons[3..] + .iter() + .map(|button| ChildView::new(button).finish()) + .collect::>(); + Flex::column() + .with_spacing(6.) + .with_child( + Flex::row() + .with_spacing(8.) + .with_children(first_row) + .finish(), + ) + .with_child( + Flex::row() + .with_spacing(8.) + .with_children(second_row) + .finish(), + ) + .finish() + } + + fn render_openai_model_row( + &self, + appearance: &Appearance, + index: usize, + model: &OpenAIModelConfig, + ) -> Box { + let model_info = Flex::column() + .with_spacing(4.) + .with_child( + Text::new_inline(model.display_name.clone(), appearance.ui_font_family(), 12.) + .with_color(appearance.theme().active_ui_text_color().into()) + .with_clip(ClipConfig::end()) + .finish(), + ) + .with_child( + Text::new_inline( + model.model_id.clone(), + appearance.monospace_font_family(), + 10., + ) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .with_clip(ClipConfig::end()) + .finish(), + ) + .finish(); + + let context_input = appearance + .ui_builder() + .text_input(self.model_context_editors[index].clone()) + .with_style(UiComponentStyles { + padding: Some(Coords { + top: 8., + bottom: 8., + left: 8., + right: 8., + }), + background: Some(appearance.theme().surface_1().into()), + ..Default::default() + }) + .build() + .finish(); + + Container::new( + Flex::row() + .with_spacing(12.) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child( + ConstrainedBox::new( + appearance + .ui_builder() + .switch(self.model_switches[index].clone()) + .check(model.enabled) + .build() + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action( + ProviderSetupModalBodyAction::ToggleModel(index), + ); + }) + .finish(), + ) + .with_width(52.) + .finish(), + ) + .with_child(ConstrainedBox::new(model_info).with_width(250.).finish()) + .with_child(ConstrainedBox::new(context_input).with_width(140.).finish()) + .with_child( + ConstrainedBox::new(self.render_model_capabilities(appearance, index)) + .with_width(330.) + .finish(), + ) + .finish(), + ) + .with_padding(Padding::uniform(12.).with_vertical(10.)) + .with_border(Border::bottom(1.).with_border_fill(appearance.theme().outline())) .finish() } @@ -1382,12 +1730,70 @@ impl ProviderSetupModalBody { .finish(); } if self.provider_type == ProviderSetupProviderType::Acp { + let option_rows = self + .draft_acp + .config_options + .iter() + .filter(|option| { + matches!( + option.category.as_deref(), + Some("model") | Some("thought_level") | Some("mode") + ) + }) + .map(|option| { + let values = option + .options + .iter() + .map(|value| value.name.as_str()) + .collect::>() + .join(", "); + Flex::column() + .with_spacing(2.) + .with_child( + Text::new( + option.name.clone(), + appearance.ui_font_family(), + INPUT_FONT_SIZE, + ) + .with_color(appearance.theme().active_ui_text_color().into()) + .with_style(Properties::default().weight(Weight::Bold)) + .finish(), + ) + .with_child( + Text::new( + if values.is_empty() { + option.current_value.to_string() + } else { + values + }, + appearance.monospace_font_family(), + 10., + ) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .soft_wrap(true) + .finish(), + ) + .finish() + }) + .collect::>(); + let catalog = if option_rows.is_empty() { + Text::new( + "No model or mode catalog has been discovered yet. Continue to test the ACP agent.", + appearance.ui_font_family(), + INPUT_FONT_SIZE, + ) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .soft_wrap(true) + .finish() + } else { + self.render_model_table(appearance, option_rows, 10.) + }; return Flex::column() .with_spacing(12.) .with_child(self.render_input(appearance, "Connection name", &self.name_editor)) .with_child( Text::new( - "ACP owns model selection. The configured agent runtime was checked before this step.", + "ACP-discovered models and modes are exposed as selectable combinations in Galaxy's model picker.", appearance.ui_font_family(), INPUT_FONT_SIZE, ) @@ -1395,67 +1801,25 @@ impl ProviderSetupModalBody { .soft_wrap(true) .finish(), ) + .with_child(catalog) .finish(); } - let mut rows = Vec::with_capacity(self.draft_models.len()); - for (index, model) in self.draft_models.iter().enumerate() { - let modes = if model.reasoning_efforts.is_empty() { - "Standard".to_string() - } else { - model.reasoning_efforts.join(", ") - }; - let info = Flex::column() - .with_spacing(2.) - .with_child( - Text::new( - model.display_name.clone(), - appearance.ui_font_family(), - INPUT_FONT_SIZE, - ) - .with_color(appearance.theme().active_ui_text_color().into()) - .finish(), - ) - .with_child( - Text::new( - format!("{} ยท modes: {modes}", model.model_id), - appearance.monospace_font_family(), - 10., - ) - .with_color(appearance.theme().nonactive_ui_text_color().into()) - .soft_wrap(true) - .finish(), - ) - .finish(); - rows.push( - Flex::row() - .with_spacing(10.) - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_child( - appearance - .ui_builder() - .switch(self.model_switches[index].clone()) - .check(model.enabled) - .build() - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action( - ProviderSetupModalBodyAction::ToggleModel(index), - ); - }) - .finish(), - ) - .with_child(info) - .finish(), - ); - } + let mut rows = vec![self.render_model_table_header(appearance)]; + rows.extend( + self.draft_models + .iter() + .enumerate() + .map(|(index, model)| self.render_openai_model_row(appearance, index, model)), + ); - let table = self.render_model_table(appearance, rows, 12.); + let table = self.render_model_table(appearance, rows, 0.); Flex::column() .with_spacing(12.) .with_child(self.render_input(appearance, "Connection name", &self.name_editor)) .with_child( Text::new( - "Choose which models Galaxy should make available. Reasoning modes remain selectable from the model picker.", + "Enable the models Galaxy should offer. Context is the maximum input window. Capabilities use Auto by default and can be overridden per model.", appearance.ui_font_family(), INPUT_FONT_SIZE, ) @@ -1631,6 +1995,17 @@ impl TypedActionView for ProviderSetupModalBody { ProviderSetupModalBodyAction::Cancel => { ctx.emit(ProviderSetupModalBodyEvent::Close); } + ProviderSetupModalBodyAction::SelectAcpAgent(agent_id) => { + self.draft_acp.agent_id = agent_id.clone(); + if !agent_id.eq_ignore_ascii_case("custom") { + self.draft_acp.command.clear(); + self.draft_acp.args.clear(); + } + self.sync_editors(ctx); + self.sync_acp_agent_buttons(ctx); + self.update_next_button(ctx); + ctx.notify(); + } ProviderSetupModalBodyAction::ToggleModel(index) => { if let Some(model) = self.draft_models.get_mut(*index) { model.enabled = !model.enabled; @@ -1638,6 +2013,16 @@ impl TypedActionView for ProviderSetupModalBody { ctx.notify(); } } + ProviderSetupModalBodyAction::CycleModelCapability(index, capability) => { + if let Some(model) = self.draft_models.get_mut(*index) { + let key = capability.setting_key().to_string(); + let next = model.capability_override(&key).next(); + model.capability_overrides.insert(key, next); + self.update_next_button(ctx); + self.sync_model_switches(ctx); + ctx.notify(); + } + } ProviderSetupModalBodyAction::ConnectChatGPT => { #[cfg(not(target_family = "wasm"))] ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx)); diff --git a/crates/acp/src/config.rs b/crates/acp/src/config.rs index abfb5434..d225cd2b 100644 --- a/crates/acp/src/config.rs +++ b/crates/acp/src/config.rs @@ -10,12 +10,109 @@ use agent_client_protocol::AcpAgentConfig; use crate::{DenyByDefaultPermissionHandler, PermissionHandler}; -/// Pinned version of the official Codex ACP adapter. -pub const CODEX_ACP_NPM_VERSION: &str = "1.1.7"; +/// Version of the official Codex ACP adapter supported by the built-in setup. +pub const CODEX_ACP_NPM_VERSION: &str = "1.1.14"; /// Pinned version of OpenCode used by the built-in ACP launch preset. pub const OPENCODE_NPM_VERSION: &str = "1.18.9"; +/// A known ACP client that can be selected in Galaxy settings. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AcpKnownAgent { + pub id: &'static str, + pub name: &'static str, + pub description: &'static str, + pub command: &'static str, + pub args: &'static [&'static str], +} + +/// Curated ACP Registry catalog. Launch commands are intentionally local-only: +/// Galaxy never installs or downloads an agent on the user's behalf. +pub const KNOWN_ACP_AGENTS: &[AcpKnownAgent] = &[ + AcpKnownAgent { + id: "codex", + name: "Codex", + description: "OpenAI's coding assistant", + command: "codex", + args: &[], + }, + AcpKnownAgent { + id: "opencode", + name: "OpenCode", + description: "Open source coding agent", + command: "opencode", + args: &["acp"], + }, + AcpKnownAgent { + id: "claude-acp", + name: "Claude Agent", + description: "Anthropic's coding agent", + command: "claude-agent-acp", + args: &[], + }, + AcpKnownAgent { + id: "gemini", + name: "Gemini CLI", + description: "Google's coding agent", + command: "gemini", + args: &["--acp"], + }, + AcpKnownAgent { + id: "cline", + name: "Cline", + description: "Autonomous coding agent", + command: "cline", + args: &["--acp"], + }, + AcpKnownAgent { + id: "cursor", + name: "Cursor", + description: "Cursor's coding agent", + command: "cursor-agent", + args: &["acp"], + }, + AcpKnownAgent { + id: "github-copilot-cli", + name: "GitHub Copilot", + description: "GitHub's AI pair programmer", + command: "copilot", + args: &["--acp"], + }, + AcpKnownAgent { + id: "goose", + name: "Goose", + description: "Block's open source AI agent", + command: "goose", + args: &["acp"], + }, + AcpKnownAgent { + id: "auggie", + name: "Auggie CLI", + description: "Augment Code's coding agent", + command: "auggie", + args: &["--acp"], + }, +]; + +pub fn known_acp_agents() -> &'static [AcpKnownAgent] { + KNOWN_ACP_AGENTS +} + +/// Resolve a registry-listed agent from the local PATH. +pub fn resolve_known_acp_agent(agent_id: &str) -> Result { + let agent = known_acp_agents() + .iter() + .find(|agent| agent.id.eq_ignore_ascii_case(agent_id.trim())) + .ok_or_else(|| format!("Unknown ACP agent: {agent_id:?}"))?; + let command = executable_on_path(agent.command).ok_or_else(|| { + format!( + "{} is not installed or could not be found on PATH (expected `{}`). Install it or choose Custom.", + agent.name, agent.command + ) + })?; + Ok(AcpLaunchConfig::new(command).args(agent.args.iter().copied())) +} + const DEFAULT_CANCELLATION_GRACE_PERIOD: Duration = Duration::from_secs(5); const DEFAULT_INITIALIZATION_TIMEOUT: Duration = Duration::from_secs(30); const DEFAULT_AUTHENTICATION_TIMEOUT: Duration = Duration::from_secs(5 * 60); @@ -37,7 +134,7 @@ impl AcpAgentPreset { pub fn launch_config(self) -> AcpLaunchConfig { match self { Self::Codex => AcpLaunchConfig::new("npx") - .args(vec![ + .args([ "--yes".to_owned(), format!("@agentclientprotocol/codex-acp@{CODEX_ACP_NPM_VERSION}"), ]) @@ -61,9 +158,9 @@ impl AcpAgentPreset { /// Resolves the best available executable for this preset. /// - /// OpenCode's native binary is preferred when installed. The Codex adapter - /// uses `npx` when available and can run through Bun's Node compatibility - /// mode. OpenCode's npm wrapper requires Node during installation. + /// OpenCode's native binary is preferred when installed. Codex runs its ACP + /// adapter through npx, while CODEX_PATH points at the user's installed + /// Codex CLI rather than downloading a second Codex installation. pub fn resolve_launch_config(self) -> Result { self.resolve_launch_config_with(executable_on_path) } @@ -74,33 +171,30 @@ impl AcpAgentPreset { ) -> Result { match self { Self::Codex => { - let (command, args) = if let Some(command) = resolve("npx") { - ( - command, - vec![ - "--yes".to_owned(), - format!("@agentclientprotocol/codex-acp@{CODEX_ACP_NPM_VERSION}"), - ], - ) - } else if let Some(command) = resolve("bunx") { - ( - command, - vec![ - "--bun".to_owned(), - format!("@agentclientprotocol/codex-acp@{CODEX_ACP_NPM_VERSION}"), - ], - ) - } else { + let Some(codex) = resolve("codex") else { return Err( - "Codex ACP requires npx or bunx; install Node.js/npm or Bun, or configure a custom ACP executable" + "Codex ACP requires the locally installed codex CLI; install Codex or configure a custom ACP executable" .to_owned(), ); }; - Ok(AcpLaunchConfig::new(command) - .args(args) + let launch = if let Some(adapter) = resolve("codex-acp") { + AcpLaunchConfig::new(adapter) + } else if let Some(npx) = resolve("npx") { + AcpLaunchConfig::new(npx).args([ + "--yes".to_owned(), + format!("@agentclientprotocol/codex-acp@{CODEX_ACP_NPM_VERSION}"), + ]) + } else { + return Err( + "Codex ACP requires either a local codex-acp executable or npx; install the ACP adapter, install Node.js/npm, or configure a custom ACP executable" + .to_owned(), + ); + }; + Ok(launch .preferred_auth_method("chat-gpt") .env("DEFAULT_AUTH_REQUEST", r#"{"methodId":"chat-gpt"}"#) - .env("INITIAL_AGENT_MODE", "read-only")) + .env("INITIAL_AGENT_MODE", "read-only") + .codex_path(codex)) } Self::OpenCode => { if let Some(command) = resolve("opencode") { diff --git a/crates/acp/src/config_tests.rs b/crates/acp/src/config_tests.rs index d580006b..1dbe295c 100644 --- a/crates/acp/src/config_tests.rs +++ b/crates/acp/src/config_tests.rs @@ -4,7 +4,7 @@ use std::time::Duration; use super::*; #[test] -fn codex_preset_is_version_pinned() { +fn codex_preset_uses_the_adapter_with_npx() { let launch = AcpAgentPreset::Codex.launch_config(); assert_eq!(launch.command, PathBuf::from("npx")); @@ -62,20 +62,28 @@ fn resolved_opencode_prefers_the_native_executable() { } #[test] -fn resolved_codex_falls_back_to_bun_compatibility_mode() { - let resolve = |command: &str| (command == "bunx").then(|| PathBuf::from("/opt/bin/bunx")); +fn resolved_codex_uses_npx_adapter_and_local_cli() { + let resolve = |command: &str| match command { + "npx" => Some(PathBuf::from("/opt/bin/npx")), + "codex" => Some(PathBuf::from("/opt/homebrew/bin/codex")), + _ => None, + }; let codex = AcpAgentPreset::Codex .resolve_launch_config_with(resolve) .unwrap(); - assert_eq!(codex.command, PathBuf::from("/opt/bin/bunx")); + assert_eq!(codex.command, PathBuf::from("/opt/bin/npx")); assert_eq!( codex.args, vec![ - "--bun".to_owned(), + "--yes".to_owned(), format!("@agentclientprotocol/codex-acp@{CODEX_ACP_NPM_VERSION}") ] ); + assert_eq!( + codex.env.get("CODEX_PATH").map(String::as_str), + Some("/opt/homebrew/bin/codex") + ); assert_eq!( codex.env.get("INITIAL_AGENT_MODE").map(String::as_str), Some("read-only") @@ -89,13 +97,32 @@ fn resolved_codex_falls_back_to_bun_compatibility_mode() { ); } +#[test] +fn resolved_codex_falls_back_to_local_adapter_without_npx() { + let resolve = |command: &str| match command { + "codex" => Some(PathBuf::from("/opt/homebrew/bin/codex")), + "codex-acp" => Some(PathBuf::from("/opt/bin/codex-acp")), + _ => None, + }; + let codex = AcpAgentPreset::Codex + .resolve_launch_config_with(resolve) + .unwrap(); + + assert_eq!(codex.command, PathBuf::from("/opt/bin/codex-acp")); + assert!(codex.args.is_empty()); + assert_eq!( + codex.env.get("CODEX_PATH").map(String::as_str), + Some("/opt/homebrew/bin/codex") + ); +} + #[test] fn resolved_presets_explain_missing_launchers() { let error = AcpAgentPreset::Codex .resolve_launch_config_with(|_| None) .unwrap_err(); - assert!(error.contains("requires npx or bunx")); + assert!(error.contains("requires the locally installed codex CLI")); let opencode_error = AcpAgentPreset::OpenCode .resolve_launch_config_with(|command| { @@ -103,6 +130,13 @@ fn resolved_presets_explain_missing_launchers() { }) .unwrap_err(); assert!(opencode_error.contains("requires the opencode executable or npx")); + + let codex_adapter_error = AcpAgentPreset::Codex + .resolve_launch_config_with(|command| { + (command == "codex").then(|| PathBuf::from("/opt/homebrew/bin/codex")) + }) + .unwrap_err(); + assert!(codex_adapter_error.contains("requires either a local codex-acp executable or npx")); } #[test] diff --git a/crates/acp/src/lib.rs b/crates/acp/src/lib.rs index e26ec849..ccee03c2 100644 --- a/crates/acp/src/lib.rs +++ b/crates/acp/src/lib.rs @@ -21,7 +21,8 @@ pub use agent_runtime::{ AcpAgentRuntime, AcpAgentRuntimeConfig, AcpRuntimeState, AcpRuntimeStateHandle, }; pub use config::{ - AcpAgentPreset, AcpLaunchConfig, AcpManagerConfig, CODEX_ACP_NPM_VERSION, OPENCODE_NPM_VERSION, + known_acp_agents, resolve_known_acp_agent, AcpAgentPreset, AcpKnownAgent, AcpLaunchConfig, + AcpManagerConfig, CODEX_ACP_NPM_VERSION, KNOWN_ACP_AGENTS, OPENCODE_NPM_VERSION, }; pub use events::AcpEvent; pub use permissions::{ diff --git a/crates/galaxy_agent_rig/src/chatgpt.rs b/crates/galaxy_agent_rig/src/chatgpt.rs index 76c2062d..12214b49 100644 --- a/crates/galaxy_agent_rig/src/chatgpt.rs +++ b/crates/galaxy_agent_rig/src/chatgpt.rs @@ -159,7 +159,7 @@ impl AgentRuntime for ChatGPTSubscriptionRuntime { request, self.config.max_output_tokens, true, - false, + true, additional_params, )?; diff --git a/crates/galaxy_agent_rig/src/openai_compatible.rs b/crates/galaxy_agent_rig/src/openai_compatible.rs index 9dfc9dee..ffaf9b54 100644 --- a/crates/galaxy_agent_rig/src/openai_compatible.rs +++ b/crates/galaxy_agent_rig/src/openai_compatible.rs @@ -95,7 +95,7 @@ fn build_completion_request( request, configured_max_output_tokens, supports_system_messages, - false, + true, Some(serde_json::json!({ "stream_options": { "include_usage": true } })),