use ai::api_keys::{ApiKeyManager, ApiKeyManagerEvent}; use indexmap::IndexMap; use instant::{Duration, Instant}; use parking_lot::FairMutex; use pathfinder_color::ColorU; use pathfinder_geometry::vector::vec2f; use std::sync::Arc; use warpui::{ elements::{ Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DropShadow, Empty, Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement as _, ParentOffsetBounds, Percentage, PositionedElementAnchor, PositionedElementOffsetBounds, Radius, Rect, SavePosition, Stack, Text, DEFAULT_UI_LINE_HEIGHT_RATIO, }, platform::Cursor, text_layout::ClipConfig, ui_components::components::UiComponent, AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity as _, TypedActionView, View, ViewContext, ViewHandle, }; const SIDECAR_HORIZONTAL_GAP: f32 = 8.; const SIDECAR_POSITION_ID: &str = "model_sidecar_panel"; use crate::{ ai::{ blocklist::{ prompt::PromptIconButtonTheme, BlocklistAIController, BlocklistAIControllerEvent, BlocklistAIInputEvent, BlocklistAIInputModel, }, execution_profiles::{ model_menu_items::{available_model_menu_items, has_reasoning_variants, is_auto}, profiles::{AIExecutionProfilesModel, AIExecutionProfilesModelEvent, ClientProfileId}, }, llms::{ dedupe_model_display_names, is_using_api_key_for_provider, LLMId, LLMInfo, LLMPreferences, LLMPreferencesEvent, LLMSpec, }, }, appearance::Appearance, cloud_object::model::generic_string_model::StringModel, context_chips::{ display_chip::{udi_font_size, udi_icon_size}, spacing, }, menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields}, settings_view::SettingsSection, terminal::view::ambient_agent::AmbientAgentViewModel, terminal::{ input::{MenuPositioning, MenuPositioningProvider}, TerminalModel, }, ui_components::icons::Icon, view_components::{ action_button::{ActionButton, ActionButtonTheme, ButtonSize, SecondaryTheme}, FeaturePopup, NewFeaturePopupEvent, NewFeaturePopupLabel, }, workspace::WorkspaceAction, }; use warp_core::ui::theme::{color::internal_colors, Fill}; use warp_core::{ features::FeatureFlag, ui::color::{coloru_with_opacity, Opacity}, }; const MENU_WIDTH: f32 = 280.; const NEW_MODEL_CHOICES_POPUP_DELAY: Duration = Duration::from_millis(500); const BLURRED_OPACITY: Opacity = 50; const SEPARATOR_WIDTH: f32 = 1.0; const CORNER_RADIUS: f32 = 4.0; const BORDER_WIDTH: f32 = 1.0; /// Inner rounded corners are 1px smaller than the outer border radius const INNER_CORNER_RADIUS: f32 = CORNER_RADIUS - BORDER_WIDTH; const BASE_FONT_SIZE: f32 = 10.0; const HORIZONTAL_PADDING_SCALE: f32 = 0.35; const VERTICAL_PADDING: f32 = 2.5; const MIN_HORIZONTAL_PADDING: f32 = 3.5; const ICON_SPACING: f32 = 8.0; const MAX_PROFILE_NAME_WIDTH_SCALE_FACTOR: f32 = 10.0; const PROFILE_SELECTOR_POSITION_ID: &str = "profile_selector"; pub fn calculate_scaled_font_size(appearance: &warp_core::ui::appearance::Appearance) -> f32 { if FeatureFlag::AgentView.is_enabled() { udi_font_size(appearance) } else { BASE_FONT_SIZE * appearance.monospace_ui_scalar() } } /// Calculate the maximum width for profile name text (we will clip to this width) pub fn calculate_max_profile_name_width(appearance: &warp_core::ui::appearance::Appearance) -> f32 { let scaled_font_size = calculate_scaled_font_size(appearance); scaled_font_size * MAX_PROFILE_NAME_WIDTH_SCALE_FACTOR } #[derive(Clone, Debug)] enum ButtonTextColor { Fill(Fill), } impl ButtonTextColor { fn to_color_u(&self, _appearance: &Appearance) -> pathfinder_color::ColorU { match self { ButtonTextColor::Fill(fill) => fill.into_solid(), } } } /// Unified theme for profile and model selector buttons #[derive(Clone)] struct SelectorChipTheme { text_color: ButtonTextColor, is_blurred: bool, } impl ActionButtonTheme for SelectorChipTheme { fn background(&self, hovered: bool, appearance: &Appearance) -> Option { let theme = appearance.theme(); Some(if hovered { theme.surface_2() } else { theme.surface_1() }) } fn text_color( &self, _hovered: bool, _background: Option, appearance: &Appearance, ) -> pathfinder_color::ColorU { let color = self.text_color.to_color_u(appearance); if self.is_blurred { coloru_with_opacity(color, BLURRED_OPACITY) } else { color } } fn font_properties(&self) -> Option { if FeatureFlag::CloudModeInputV2.is_enabled() { Some(warpui::fonts::Properties { weight: warpui::fonts::Weight::Semibold, ..Default::default() }) } else { None } } } /// A unified profile and model selector component that combines both selectors /// into a single component. pub struct ProfileModelSelector { profile_button: ViewHandle, model_button: ViewHandle, profile_compact_button: ViewHandle, model_compact_button: ViewHandle, profile_dropdown: ViewHandle>, model_dropdown: ViewHandle>, model_spec_sidecar: ModelSpecSidecar, is_profile_menu_open: bool, is_model_menu_open: bool, terminal_view_id: EntityId, profile_mouse_state: MouseStateHandle, model_mouse_state: MouseStateHandle, menu_positioning_provider: Arc, is_blurred: bool, new_model_popup: ViewHandle, input_model: ModelHandle, ambient_agent_view_model: ModelHandle, render_compact: bool, hovered_llm_info: Option, manage_api_key_button: ViewHandle, terminal_model: Arc>, all_model_choices: Vec, } pub enum ProfileModelSelectorEvent { OpenSettings(SettingsSection), MenuVisibilityChanged { open: bool }, ToggleInlineModelSelector, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum ProfileModelSelectorAction { SelectProfile(ClientProfileId), SelectModel(LLMId), SelectAutoModel, SelectReasoningModel(String), ManageProfiles, ToggleProfileMenu, ToggleModelMenu, } /// Menu type for get_selected_llm_info lookups enum MenuType { Main, Sidecar, } /// Identifies which sidecar panel we're working with #[derive(Clone)] enum ModelSpecSidecarKind { Auto, Reasoning, } /// Encapsulates state for a sidecar panel (auto models or reasoning levels) struct ModelSpecSidecar { dropdown: ViewHandle>, hovered_info: Option, active_kind: Option, } impl ProfileModelSelectorAction { pub fn selected_model_id(&self) -> Option { match self { ProfileModelSelectorAction::SelectModel(id) => Some(id.clone()), _ => None, } } } impl ProfileModelSelector { pub fn new( menu_positioning_provider: Arc, terminal_view_id: EntityId, input_model: ModelHandle, ambient_agent_view_model: ModelHandle, terminal_model: Arc>, controller: Option>, ctx: &mut ViewContext, ) -> Self { let profile_button = ctx.add_typed_action_view(|ctx| { let appearance = Appearance::as_ref(ctx); ActionButton::new( "", SelectorChipTheme { text_color: ButtonTextColor::Fill( appearance .theme() .sub_text_color(appearance.theme().surface_1()), ), is_blurred: false, }, ) .with_disabled_theme(SelectorChipTheme { text_color: ButtonTextColor::Fill( internal_colors::text_disabled( appearance.theme(), appearance.theme().surface_1(), ) .into(), ), is_blurred: false, }) .with_tooltip("Choose an AI execution profile") .with_size(ButtonSize::UDIButton) .with_icon(Icon::Psychology) }); let model_button = ctx.add_typed_action_view(|ctx| { let appearance = Appearance::as_ref(ctx); ActionButton::new( "", SelectorChipTheme { text_color: ButtonTextColor::Fill( appearance .theme() .sub_text_color(appearance.theme().surface_1()), ), is_blurred: false, }, ) .with_disabled_theme(SelectorChipTheme { text_color: ButtonTextColor::Fill( internal_colors::text_disabled( appearance.theme(), appearance.theme().surface_1(), ) .into(), ), is_blurred: false, }) .with_tooltip("Choose an agent model") .with_size(ButtonSize::UDIButton) }); let profile_compact_button = ctx.add_typed_action_view(|_| { ActionButton::new("", PromptIconButtonTheme::new(false)) .with_icon(Icon::Psychology) .with_tooltip("Choose an AI execution profile") .with_size(ButtonSize::UDIButton) .on_click(|ctx| { ctx.dispatch_typed_action(ProfileModelSelectorAction::ToggleProfileMenu); }) }); let model_compact_button = ctx.add_typed_action_view(|_| { ActionButton::new("", PromptIconButtonTheme::new(false)) .with_icon(Icon::Neurology) .with_tooltip("Choose an agent model") .with_size(ButtonSize::UDIButton) .on_click(|ctx| { ctx.dispatch_typed_action(ProfileModelSelectorAction::ToggleModelMenu); }) }); let profile_dropdown = ctx.add_typed_action_view(|_ctx| { Menu::new() .prevent_interaction_with_other_elements() .with_drop_shadow() }); let model_dropdown = ctx.add_typed_action_view(|_ctx| { Menu::new() .with_ignore_hover_when_covered() .with_safe_triangle() .prevent_interaction_with_other_elements() .with_drop_shadow() }); let sidecar_dropdown = ctx.add_typed_action_view(|_ctx| Menu::new()); let new_model_popup = ctx.add_typed_action_view(|_ctx| { FeaturePopup::new_feature(NewFeaturePopupLabel::FromCallable(Box::new(|ctx| { let llm_preferences = LLMPreferences::as_ref(ctx); let new_choices = llm_preferences.new_choices_since_last_update(); if let Some(new_choices) = new_choices { let deduped_names = dedupe_model_display_names(new_choices.iter()); let max_display = 5; let has_overflow = deduped_names.len() > max_display; let display_names = &deduped_names[..deduped_names.len().min(max_display)]; let mut label = display_names .iter() .map(|name| { if *name == "auto" { "auto-select the best model for the task" } else { name } }) .collect::>() .join(", "); if has_overflow { label += ", ..."; } label } else { "New models available".to_string() } }))) }); ctx.subscribe_to_view(&profile_dropdown, |me, _, event, ctx| { if let MenuEvent::Close { .. } = event { me.set_profile_menu_visibility(false, ctx); } }); ctx.subscribe_to_view(&model_dropdown, |me, _, event, ctx| { match event { MenuEvent::Close { .. } => { me.set_model_menu_visibility(false, ctx); // Reset hovered llm info to the selected model let selected_index = me.model_dropdown.read(ctx, |menu, _| menu.selected_index()); me.set_hovered_llm_info(selected_index, ctx); } MenuEvent::ItemSelected => { let selected_index = me.model_dropdown.read(ctx, |menu, _| menu.selected_index()); me.set_hovered_llm_info(selected_index, ctx); ctx.notify(); } MenuEvent::ItemHovered => { if me.is_model_menu_open { let hovered_index = me.model_dropdown.read(ctx, |menu, _| menu.hovered_index()); if hovered_index.is_some() { me.set_hovered_llm_info(hovered_index, ctx); ctx.notify(); } } } } }); ctx.subscribe_to_view(&sidecar_dropdown, |me, _, event, ctx| match event { MenuEvent::Close { .. } => {} MenuEvent::ItemSelected => { let selected_index = me .model_spec_sidecar .dropdown .read(ctx, |menu, _| menu.selected_index()); me.set_sidecar_hovered_info(selected_index, ctx); ctx.notify(); } MenuEvent::ItemHovered => { if me.is_model_menu_open { let hovered_index = me .model_spec_sidecar .dropdown .read(ctx, |menu, _| menu.hovered_index()); if hovered_index.is_some() { me.set_sidecar_hovered_info(hovered_index, ctx); ctx.notify(); } } } }); ctx.subscribe_to_view(&new_model_popup, move |_me, _, event, ctx| { if matches!(event, NewFeaturePopupEvent::Dismissed) { LLMPreferences::handle(ctx).update(ctx, |preferences, _| { preferences.hide_llm_popup(terminal_view_id) }); ctx.notify(); } }); ctx.subscribe_to_model(&input_model, move |_me, _, event, ctx| match event { BlocklistAIInputEvent::InputTypeChanged { config } | BlocklistAIInputEvent::LockChanged { config } => { if config.is_locked && !config.input_type.is_ai() { let llm_preferences = LLMPreferences::as_ref(ctx); llm_preferences.hide_llm_popup(terminal_view_id); } else if config.input_type.is_ai() { ctx.spawn( warpui::r#async::Timer::after(NEW_MODEL_CHOICES_POPUP_DELAY), |_, _, ctx| { ctx.notify(); }, ); } ctx.notify(); } }); ctx.subscribe_to_model( &LLMPreferences::handle(ctx), |me, _, event, ctx| match event { LLMPreferencesEvent::UpdatedAvailableLLMs => { me.refresh_state(ctx); me.new_model_popup.update(ctx, |_popup, ctx| { ctx.notify(); }); ctx.notify(); } LLMPreferencesEvent::UpdatedActiveAgentModeLLM => { me.refresh_state(ctx); me.new_model_popup.update(ctx, |_popup, ctx| { ctx.notify(); }); ctx.notify(); } _ => (), }, ); if let Some(controller) = &controller { ctx.subscribe_to_model(controller, |me, _, event, ctx| { if let BlocklistAIControllerEvent::SentRequest { .. } = event { let llm_preferences = LLMPreferences::as_ref(ctx); llm_preferences.hide_llm_popup(me.terminal_view_id); ctx.notify(); } }); } ctx.subscribe_to_model(&Appearance::handle(ctx), |me, _, _, ctx| { me.handle_appearance_change(ctx); }); // Refresh model menu when BYO API keys update so the key icons reflect the latest state. ctx.subscribe_to_model( &ApiKeyManager::handle(ctx), |me, _model, _event: &ApiKeyManagerEvent, ctx| { me.refresh_model_menu(ctx); ctx.notify(); }, ); ctx.subscribe_to_model( &AIExecutionProfilesModel::handle(ctx), |me, _, event, ctx| { match event { AIExecutionProfilesModelEvent::ProfileCreated | AIExecutionProfilesModelEvent::ProfileDeleted | AIExecutionProfilesModelEvent::ProfileUpdated(_) => { // Re-render when profiles are added or deleted to show/hide profile selector me.refresh_state(ctx); } AIExecutionProfilesModelEvent::UpdatedActiveProfile { terminal_view_id } if *terminal_view_id == me.terminal_view_id => { me.refresh_state(ctx); } _ => (), } }, ); let manage_api_key_button = ctx.add_typed_action_view(|_ctx| { ActionButton::new("Manage", SecondaryTheme) .with_tooltip("Manage API keys") .with_size(ButtonSize::XSmall) .on_click(|ctx| { ctx.dispatch_typed_action(WorkspaceAction::ShowSettingsPageWithSearch { search_query: "api".to_string(), section: Some(SettingsSection::WarpAgent), }); }) }); let mut me = Self { profile_button, model_button, profile_compact_button, model_compact_button, profile_dropdown, model_dropdown, model_spec_sidecar: ModelSpecSidecar { dropdown: sidecar_dropdown, hovered_info: None, active_kind: None, }, is_profile_menu_open: false, is_model_menu_open: false, terminal_view_id, profile_mouse_state: Default::default(), model_mouse_state: Default::default(), menu_positioning_provider, is_blurred: false, new_model_popup, input_model, ambient_agent_view_model, render_compact: false, hovered_llm_info: None, manage_api_key_button, terminal_model, all_model_choices: Vec::new(), }; me.refresh_state(ctx); me } pub fn set_profile_menu_visibility(&mut self, is_open: bool, ctx: &mut ViewContext) { if self.is_profile_menu_open == is_open { return; } self.is_profile_menu_open = is_open; self.is_model_menu_open = false; if is_open { ctx.focus(&self.profile_dropdown); } ctx.emit(ProfileModelSelectorEvent::MenuVisibilityChanged { open: is_open }); ctx.notify(); } pub fn set_model_menu_visibility(&mut self, is_open: bool, ctx: &mut ViewContext) { if self.is_model_menu_open == is_open { return; } self.is_model_menu_open = is_open; self.is_profile_menu_open = false; if is_open { LLMPreferences::handle(ctx).update(ctx, |preferences, _| { preferences.hide_llm_popup(self.terminal_view_id) }); // Initialize hovered_llm_info to the currently selected model let selected_index = self .model_dropdown .read(ctx, |menu, _| menu.selected_index()); self.set_hovered_llm_info(selected_index, ctx); log::info!("Focusing model menu"); ctx.focus(&self.model_dropdown); } ctx.emit(ProfileModelSelectorEvent::MenuVisibilityChanged { open: is_open }); ctx.notify(); } pub fn is_open(&self) -> bool { self.is_profile_menu_open || self.is_model_menu_open } pub fn model_menu_item_position_id(&self, llm_id: &LLMId) -> String { format!("{PROFILE_SELECTOR_POSITION_ID}_{llm_id}") } fn refresh_state(&mut self, ctx: &mut ViewContext) { self.refresh_profile_menu(ctx); self.refresh_model_menu(ctx); let profiles_model = AIExecutionProfilesModel::as_ref(ctx); if profiles_model.has_multiple_profiles() { let profile_name = { let active_profile = profiles_model.active_profile(Some(self.terminal_view_id), ctx); active_profile.data().display_name() }; self.profile_button.update(ctx, |button, ctx| { button.set_label(profile_name, ctx); }); } let model_name = { let llm_preferences = LLMPreferences::as_ref(ctx); let active_llm = if FeatureFlag::InlineMenuHeaders.is_enabled() && self .terminal_model .lock() .block_list() .active_block() .is_agent_in_control_or_tagged_in() { llm_preferences.get_active_cli_agent_model(ctx, Some(self.terminal_view_id)) } else { llm_preferences.get_active_base_model(ctx, Some(self.terminal_view_id)) }; if let Some(description) = &active_llm.description { format!("{} ({})", active_llm.display_name, description) } else { active_llm.display_name.clone() } }; self.model_button.update(ctx, |button, ctx| { button.set_label(model_name, ctx); }); ctx.notify(); } pub fn set_blurred(&mut self, is_blurred: bool, ctx: &mut ViewContext) { self.is_blurred = is_blurred; self.update_chip_themes(ctx); self.update_compact_button_themes(ctx); } pub fn set_render_compact(&mut self, render_compact: bool, ctx: &mut ViewContext) { if self.render_compact != render_compact { self.render_compact = render_compact; ctx.notify(); } } fn handle_appearance_change(&mut self, ctx: &mut ViewContext) { self.update_chip_themes(ctx); self.update_compact_button_themes(ctx); } fn update_chip_themes(&self, ctx: &mut ViewContext) { let appearance = Appearance::as_ref(ctx); let profiles_model = AIExecutionProfilesModel::as_ref(ctx); let has_multiple_profiles = profiles_model.has_multiple_profiles(); let new_theme = SelectorChipTheme { text_color: ButtonTextColor::Fill( appearance .theme() .sub_text_color(appearance.theme().surface_1()), ), is_blurred: self.is_blurred, }; let new_disabled_theme = SelectorChipTheme { text_color: ButtonTextColor::Fill( internal_colors::text_disabled(appearance.theme(), appearance.theme().surface_1()) .into(), ), is_blurred: self.is_blurred, }; // Only update profile button if there are multiple profiles if has_multiple_profiles { self.profile_button.update(ctx, |button, ctx| { button.set_theme(new_theme.clone(), ctx); button.set_disabled_theme(new_disabled_theme.clone(), ctx); button.set_disabled(self.is_blurred, ctx); }); } self.model_button.update(ctx, |button, ctx| { button.set_theme(new_theme.clone(), ctx); button.set_disabled_theme(new_disabled_theme.clone(), ctx); button.set_disabled(self.is_blurred, ctx); }); ctx.notify(); } fn update_compact_button_themes(&self, ctx: &mut ViewContext) { let theme = PromptIconButtonTheme::new(self.is_blurred); let profiles_model = AIExecutionProfilesModel::as_ref(ctx); let has_multiple_profiles = profiles_model.has_multiple_profiles(); if has_multiple_profiles { self.profile_compact_button.update(ctx, |button, ctx| { button.set_theme(theme.clone(), ctx); }); } self.model_compact_button.update(ctx, |button, ctx| { button.set_theme(theme.clone(), ctx); }); ctx.notify(); } fn refresh_profile_menu(&mut self, ctx: &mut ViewContext) { let profiles_model = AIExecutionProfilesModel::as_ref(ctx); let all_profile_ids = profiles_model.get_all_profile_ids(); let active_profile = profiles_model.active_profile(Some(self.terminal_view_id), ctx); let appearance = Appearance::as_ref(ctx); let mut menu_items = vec![ MenuItem::Header { fields: MenuItemFields::new("Profiles").with_override_text_color( appearance .theme() .sub_text_color(appearance.theme().background()) .into_solid(), ), clickable: false, right_side_fields: None, }, MenuItem::Separator, ]; for profile_id in all_profile_ids { if let Some(profile_info) = profiles_model.get_profile_by_id(profile_id, ctx) { let profile = profile_info.data(); let is_active = *active_profile.id() == profile_id; let mut fields = MenuItemFields::new(profile.display_name()); if is_active { fields = fields.with_icon(Icon::Check); } else { fields = fields.with_indent(); } menu_items.push(MenuItem::Item(fields.with_on_select_action( ProfileModelSelectorAction::SelectProfile(profile_id), ))); } } menu_items.push(MenuItem::Separator); menu_items.push(MenuItem::Item( MenuItemFields::new("Manage profiles") .with_icon(Icon::Gear) .with_on_select_action(ProfileModelSelectorAction::ManageProfiles), )); self.profile_dropdown.update(ctx, |menu, ctx| { menu.set_items(menu_items, ctx); let active_action = ProfileModelSelectorAction::SelectProfile(*active_profile.id()); menu.set_selected_by_action(&active_action, ctx); }); } fn refresh_model_menu(&mut self, ctx: &mut ViewContext) { let llm_preferences = LLMPreferences::as_ref(ctx); let active_llm = llm_preferences.get_active_base_model(ctx, Some(self.terminal_view_id)); let active_profile = AIExecutionProfilesModel::as_ref(ctx).active_profile(Some(self.terminal_view_id), ctx); let profile_base_model_id = active_profile .data() .base_model .clone() .and_then(|id| { llm_preferences .get_llm_info(&id) .map(|info| info.id.clone()) }) .unwrap_or_else(|| llm_preferences.get_default_base_model().id.clone()); let model_id_to_add_profile_default_label_to = Some(&profile_base_model_id); // Store all model choices for reasoning variant lookups self.all_model_choices = llm_preferences .get_base_llm_choices_for_agent_mode() .cloned() .collect(); // Group models by base_model_name to collapse reasoning variants. // Use "auto" as the key for all auto models so they collapse together. // Only group models that have reasoning levels - others stay separate. let mut groups: IndexMap> = IndexMap::new(); for llm in &self.all_model_choices { let key = if is_auto(llm) { "auto".to_string() } else if llm.has_reasoning_level() { llm.base_model_name().to_string() } else { llm.id.to_string() }; groups.entry(key).or_default().push(llm); } // Build collapsed list: for each group, take first model (preserves server order) let choices: Vec<_> = groups .into_iter() .filter_map(|(_, variants)| variants.into_iter().next()) .collect(); let items = available_model_menu_items( choices, |llm| { let all_refs: Vec<_> = self.all_model_choices.iter().collect(); if is_auto(llm) { ProfileModelSelectorAction::SelectAutoModel } else if has_reasoning_variants(llm, &all_refs) { ProfileModelSelectorAction::SelectReasoningModel( llm.base_model_name().to_string(), ) } else { ProfileModelSelectorAction::SelectModel(llm.id.clone()) } }, model_id_to_add_profile_default_label_to, Some(&|llm_id| self.model_menu_item_position_id(llm_id)), true, true, ctx, ); let selected_index = Self::find_selected_index(&items, active_llm); self.model_dropdown.update(ctx, |menu, ctx| { menu.set_width(MENU_WIDTH); menu.set_items(items, ctx); menu.set_selected_by_index(selected_index, ctx); ctx.notify(); }); self.set_hovered_llm_info(Some(selected_index), ctx); } fn refresh_model_spec_sidecar( &mut self, kind: &ModelSpecSidecarKind, ctx: &mut ViewContext, ) { let llm_preferences = LLMPreferences::as_ref(ctx); let active_llm = llm_preferences.get_active_base_model(ctx, Some(self.terminal_view_id)); let active_llm_id = active_llm.id.clone(); let items: Vec> = match kind { ModelSpecSidecarKind::Auto => llm_preferences .get_base_llm_choices_for_agent_mode() .filter(|llm| is_auto(llm)) .map(|llm| { let is_selected = llm.id == active_llm_id; let label = if llm.display_name.starts_with("auto (") { // Auto display names are formatted like "auto ()" // We extract the sub-variant and capitalize it for use in the sidecar menu. let trimmed = llm .display_name .trim_start_matches("auto (") .trim_end_matches(")"); // Capitalize the first letter of the auto sub-variant. let mut chars = trimmed.chars(); chars .next() .map(|first| first.to_uppercase().chain(chars).collect()) .unwrap_or_default() } else { llm.display_name.clone() }; Self::make_sidecar_item(label, &llm.id, is_selected) }) .collect(), ModelSpecSidecarKind::Reasoning => { // For Reasoning without a base_name, return empty (use refresh_model_spec_sidecar_for_model instead) Vec::new() } }; let selected_index = Self::find_sidecar_selected_index(&items, &active_llm_id); self.model_spec_sidecar.active_kind = Some(kind.clone()); self.model_spec_sidecar.dropdown.update(ctx, |menu, ctx| { menu.set_width(MENU_WIDTH); menu.set_items(items, ctx); menu.set_selected_by_index(selected_index, ctx); ctx.notify(); }); } fn refresh_model_spec_sidecar_for_model( &mut self, base_name: &str, ctx: &mut ViewContext, ) { let llm_preferences = LLMPreferences::as_ref(ctx); let active_llm = llm_preferences.get_active_base_model(ctx, Some(self.terminal_view_id)); let active_llm_id = active_llm.id.clone(); let items: Vec> = self .all_model_choices .iter() .filter(|llm| llm.base_model_name() == base_name && llm.has_reasoning_level()) .map(|llm| { let is_selected = llm.id == active_llm_id; let label = llm.reasoning_level().unwrap_or_default(); Self::make_sidecar_item(label, &llm.id, is_selected) }) .collect(); let selected_index = Self::find_sidecar_selected_index(&items, &active_llm_id); self.model_spec_sidecar.active_kind = Some(ModelSpecSidecarKind::Reasoning); self.model_spec_sidecar.dropdown.update(ctx, |menu, ctx| { menu.set_width(MENU_WIDTH); menu.set_items(items, ctx); menu.set_selected_by_index(selected_index, ctx); ctx.notify(); }); self.set_sidecar_hovered_info(Some(selected_index), ctx); } fn make_sidecar_item( label: String, llm_id: &LLMId, is_selected: bool, ) -> MenuItem { let mut fields = MenuItemFields::new(label) .with_font_size_override(14.) .with_on_select_action(ProfileModelSelectorAction::SelectModel(llm_id.clone())); if is_selected { fields = fields.with_icon(Icon::Check); } else { fields = fields.with_indent(); } fields.into_item() } fn find_sidecar_selected_index( items: &[MenuItem], active_llm_id: &LLMId, ) -> usize { items .iter() .position(|item| { if let MenuItem::Item(fields) = item { let item_model_id = item .item_on_select_action() .and_then(|action| action.selected_model_id()); !fields.is_disabled() && item_model_id.as_ref() == Some(active_llm_id) } else { false } }) .unwrap_or(0) } fn handle_sidecar_selection(&mut self, ctx: &mut ViewContext) { let index = self .model_spec_sidecar .dropdown .read(ctx, |menu, _| menu.selected_index()) .unwrap_or(0); if let Some(llm) = self.get_selected_llm_info(MenuType::Sidecar, index, ctx) { log::info!( "Selecting base agent model {} (from model selector)", &llm.id ); LLMPreferences::handle(ctx).update(ctx, |preferences, ctx| { preferences.update_preferred_agent_mode_llm(&llm.id, self.terminal_view_id, ctx); }); } self.set_model_menu_visibility(false, ctx); } fn find_selected_index( items: &[MenuItem], active_llm: &LLMInfo, ) -> usize { items .iter() .position(|item| { if let MenuItem::Item(fields) = item { let is_disabled = fields.is_disabled(); let is_active = if is_auto(active_llm) { matches!( item.item_on_select_action(), Some(ProfileModelSelectorAction::SelectAutoModel) ) } else if active_llm.has_reasoning_level() { // For models with reasoning levels, match by base_model_name matches!( item.item_on_select_action(), Some(ProfileModelSelectorAction::SelectReasoningModel(name)) if *name == active_llm.base_model_name() ) } else { let item_model_id = item .item_on_select_action() .and_then(|action| action.selected_model_id()); item_model_id.map(|id| id == active_llm.id).unwrap_or(false) }; !is_disabled && is_active } else { false } }) .or_else(|| { items.iter().position(|item| { if let MenuItem::Item(fields) = item { !fields.is_disabled() } else { false } }) }) .unwrap_or(0) } // Gets the LLMInfo of the selected model in the given menu at the given index. fn get_selected_llm_info( &self, menu_type: MenuType, index: usize, ctx: &mut ViewContext, ) -> Option { let model_dropdown = match &menu_type { MenuType::Main => &self.model_dropdown, MenuType::Sidecar => &self.model_spec_sidecar.dropdown, }; model_dropdown.read(ctx, |menu, _| { menu.items() .get(index) .and_then(|item| item.item_on_select_action()) .and_then(|action| { match action { ProfileModelSelectorAction::SelectModel(llm_id) => { LLMPreferences::as_ref(ctx).get_llm_info(llm_id).cloned() } ProfileModelSelectorAction::SelectAutoModel => { // Get the first "auto" variant as the generic auto model let llm_prefs = LLMPreferences::as_ref(ctx); llm_prefs .get_base_llm_choices_for_agent_mode() .find(|llm| is_auto(llm)) .cloned() } ProfileModelSelectorAction::SelectReasoningModel(base_name) => { // Get the first reasoning variant for this base model self.all_model_choices .iter() .find(|llm| { llm.base_model_name() == base_name && llm.has_reasoning_level() }) .cloned() } _ => None, } }) }) } fn set_hovered_llm_info(&mut self, index: Option, ctx: &mut ViewContext) { let Some(index) = index else { return; }; let llm_info = self.get_selected_llm_info(MenuType::Main, index, ctx); self.hovered_llm_info = llm_info.clone(); let shows_sidecar = llm_info .as_ref() .is_some_and(|info| is_auto(info) || self.has_multiple_reasoning_variants(info)); let shows_side_panel = shows_sidecar || llm_info.as_ref().is_some_and(|info| info.spec.is_some()); if shows_sidecar { // Read the sidecar rect from last frame to update the safe zone target let window_id = self.model_dropdown.window_id(ctx); let sidecar_rect = ctx.element_position_by_id_at_last_frame(window_id, SIDECAR_POSITION_ID); self.model_dropdown.update(ctx, |menu, ctx| { menu.set_safe_zone_target(sidecar_rect); menu.set_submenu_being_shown_for_item_index(Some(index)); ctx.notify(); }); } else { self.model_dropdown.update(ctx, |menu, ctx| { menu.set_safe_zone_target(None); menu.set_submenu_being_shown_for_item_index(if shows_side_panel { Some(index) } else { None }); ctx.notify(); }); } if let Some(info) = &llm_info { if is_auto(info) { // If hovering auto, refresh sidecar with auto variants and set hovered_info self.refresh_model_spec_sidecar(&ModelSpecSidecarKind::Auto, ctx); let auto_index = self .model_spec_sidecar .dropdown .read(ctx, |menu, _| menu.selected_index()); self.set_sidecar_hovered_info(auto_index, ctx); } else if self.has_multiple_reasoning_variants(info) { // If hovering a model with multiple reasoning variants, refresh reasoning menu self.refresh_model_spec_sidecar_for_model(info.base_model_name(), ctx); } } } fn set_sidecar_hovered_info(&mut self, index: Option, ctx: &mut ViewContext) { let index = index.unwrap_or(0); self.model_spec_sidecar.hovered_info = self.get_selected_llm_info(MenuType::Sidecar, index, ctx); } fn has_multiple_reasoning_variants(&self, llm: &LLMInfo) -> bool { let all_refs: Vec<_> = self.all_model_choices.iter().collect(); has_reasoning_variants(llm, &all_refs) } fn get_padding_values(&self, scaled_font_size: f32) -> (f32, f32) { if FeatureFlag::AgentView.is_enabled() { ( spacing::UDI_CHIP_VERTICAL_PADDING, spacing::UDI_CHIP_HORIZONTAL_PADDING, ) } else { let horizontal_padding = (scaled_font_size * HORIZONTAL_PADDING_SCALE).max(MIN_HORIZONTAL_PADDING); (VERTICAL_PADDING, horizontal_padding) } } fn get_menu_positioning(&self, app: &AppContext, is_profile: bool) -> OffsetPositioning { match self.menu_positioning_provider.menu_position(app) { MenuPositioning::BelowInputBox => { if self.render_compact { if is_profile { OffsetPositioning::offset_from_save_position_element( "profile_model_selector_profile_compact_button", vec2f(0., 4.), PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::BottomLeft, ChildAnchor::TopLeft, ) } else { OffsetPositioning::offset_from_save_position_element( "profile_model_selector_model_compact_button", vec2f(0., 4.), PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::BottomLeft, ChildAnchor::TopLeft, ) } } else { // In full mode, use the original positioning logic if is_profile { OffsetPositioning::offset_from_parent( vec2f(0., 4.), ParentOffsetBounds::WindowByPosition, ParentAnchor::BottomLeft, ChildAnchor::TopLeft, ) } else { OffsetPositioning::offset_from_save_position_element( "profile_model_selector_model_button", vec2f(0., 4.), PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::BottomLeft, ChildAnchor::TopLeft, ) } } } MenuPositioning::AboveInputBox => { if self.render_compact { if is_profile { OffsetPositioning::offset_from_save_position_element( "profile_model_selector_profile_compact_button", vec2f(0., -4.), PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::TopLeft, ChildAnchor::BottomLeft, ) } else { OffsetPositioning::offset_from_save_position_element( "profile_model_selector_model_compact_button", vec2f(0., -4.), PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::TopLeft, ChildAnchor::BottomLeft, ) } } else if is_profile { OffsetPositioning::offset_from_parent( vec2f(0., -4.), ParentOffsetBounds::WindowByPosition, ParentAnchor::TopLeft, ChildAnchor::BottomLeft, ) } else { OffsetPositioning::offset_from_save_position_element( "profile_model_selector_model_button", vec2f(0., -4.), PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::TopLeft, ChildAnchor::BottomLeft, ) } } } } fn should_render_model_sidecar_left(&self, position_id: &str, app: &AppContext) -> bool { // When AgentView is enabled, the model picker is right-aligned, so we default to // showing the sidecar on the left side to avoid overlap. let default_to_left = FeatureFlag::AgentView.is_enabled(); let window_id = self.model_dropdown.window_id(app); let Some(window) = app.windows().platform_window(window_id) else { return default_to_left; }; // If we don't have the anchor position cached yet, use the default based on AgentView. let Some(anchor_rect) = app.element_position_by_id_at_last_frame(window_id, position_id) else { return default_to_left; }; // Sidecar is positioned center-to-center off the hovered menu item's position. // Check both sides for overflow and pick the side that fits. let anchor_center_x = (anchor_rect.min_x() + anchor_rect.max_x()) / 2.0; let sidecar_half_width = MENU_WIDTH / 2.0; // Calculate where the sidecar edges would be on each side let sidecar_left_edge_if_on_left = anchor_center_x - (MENU_WIDTH + SIDECAR_HORIZONTAL_GAP) - sidecar_half_width; let sidecar_right_edge_if_on_right = anchor_center_x + (MENU_WIDTH + SIDECAR_HORIZONTAL_GAP) + sidecar_half_width; let would_overflow_left = sidecar_left_edge_if_on_left < 0.0; let would_overflow_right = sidecar_right_edge_if_on_right >= window.size().x(); // If both sides overflow, prefer the default based on AgentView state. // If only one side fits, use that side. // If neither overflows, use the default based on AgentView state. match (would_overflow_left, would_overflow_right) { (true, false) => false, // Only right fits, show on right (false, true) => true, // Only left fits, show on left _ => default_to_left, // Both fit or both overflow, use default } } fn render_profile_section(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let profiles_model = AIExecutionProfilesModel::as_ref(app); let active_profile = profiles_model.active_profile(Some(self.terminal_view_id), app); let text_color = if self.is_blurred { theme.disabled_text_color(theme.surface_1()).into() } else { theme.sub_text_color(theme.surface_1()).into() }; let scaled_font_size = calculate_scaled_font_size(appearance); // Use the same icon size as the compact UDI button to ensure consistent height let icon_size = if FeatureFlag::AgentView.is_enabled() { udi_icon_size(appearance, app) } else { appearance.monospace_font_size() - 1.0 }; let (vertical_padding, horizontal_padding) = self.get_padding_values(scaled_font_size); let profile_icon = Icon::Psychology .to_warpui_icon(Fill::Solid(text_color)) .finish(); let max_label_width = calculate_max_profile_name_width(appearance); let profile_text = ConstrainedBox::new( Text::new_inline( active_profile.data().display_name(), appearance.ui_font_family(), scaled_font_size, ) .with_color(text_color) .with_line_height_ratio(appearance.line_height_ratio()) .with_clip(ClipConfig::end()) .finish(), ) .with_max_width(max_label_width) .finish(); let content = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( Container::new( ConstrainedBox::new(profile_icon) .with_height(icon_size) .with_width(icon_size) .finish(), ) .with_margin_right(ICON_SPACING) .finish(), ) .with_child(profile_text) .finish(); let button = Container::new(content) .with_vertical_padding(vertical_padding) .with_horizontal_padding(horizontal_padding) .finish(); Hoverable::new(self.profile_mouse_state.clone(), move |state| { if state.is_hovered() { let button_with_hover = Container::new(button) .with_background(theme.surface_2()) .with_corner_radius(CornerRadius::with_left(Radius::Pixels( INNER_CORNER_RADIUS, ))) .finish(); let tooltip_text = "Choose an AI execution profile".to_owned(); let tooltip = appearance.ui_builder().tool_tip(tooltip_text); let mut stack = Stack::new(); stack.add_child(button_with_hover); stack.add_positioned_child( tooltip.build().finish(), OffsetPositioning::offset_from_parent( vec2f(0., -10.), ParentOffsetBounds::Unbounded, ParentAnchor::TopLeft, ChildAnchor::BottomLeft, ), ); stack.finish() } else { button } }) .on_click(|ctx, _app, _position| { ctx.dispatch_typed_action(ProfileModelSelectorAction::ToggleProfileMenu); }) .with_cursor(Cursor::PointingHand) .finish() } fn render_model_section(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let llm_preferences = LLMPreferences::as_ref(app); // Allow editing if composing an ambient agent query, or if the user has edit access // in a shared session (i.e., not a viewer, or is an executor). let is_composing_ambient_agent = self .ambient_agent_view_model .as_ref(app) .is_configuring_ambient_agent(); let terminal_model = self.terminal_model.lock(); let has_edit_access = is_composing_ambient_agent || !terminal_model.shared_session_status().is_viewer() || terminal_model.shared_session_status().is_executor(); let is_lrc = FeatureFlag::InlineMenuHeaders.is_enabled() && terminal_model .block_list() .active_block() .is_agent_in_control_or_tagged_in(); drop(terminal_model); let model_display_name = if is_lrc { llm_preferences .get_active_cli_agent_model(app, Some(self.terminal_view_id)) .menu_display_name() } else { llm_preferences .get_active_base_model(app, Some(self.terminal_view_id)) .menu_display_name() }; let text_color = if self.is_blurred { theme.disabled_text_color(theme.surface_1()).into() } else { theme.sub_text_color(theme.surface_1()).into() }; let scaled_font_size = calculate_scaled_font_size(appearance); let icon_size = if FeatureFlag::AgentView.is_enabled() { udi_icon_size(appearance, app) } else { appearance.monospace_font_size() - 1.0 }; let (vertical_padding, horizontal_padding) = self.get_padding_values(scaled_font_size); let model_text = Text::new_inline( model_display_name, appearance.ui_font_family(), scaled_font_size, ) .with_color(text_color) .with_line_height_ratio(appearance.line_height_ratio()) .finish(); let mut content = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center); if is_lrc { let terminal_icon = Icon::Terminal .to_warpui_icon(Fill::Solid(text_color)) .finish(); content = content.with_child( Container::new( ConstrainedBox::new(terminal_icon) .with_height(icon_size) .with_width(icon_size) .finish(), ) .with_margin_right(ICON_SPACING) .finish(), ); } content = content.with_child(model_text); // Only show chevron icon if the user can click to open the menu (i.e. has edit access) // and the InlineMenuHeaders feature flag is not enabled // (when enabled, clicking opens the inline model selector instead of a dropdown). if has_edit_access && !FeatureFlag::InlineMenuHeaders.is_enabled() { let chevron_icon = Icon::ChevronDown .to_warpui_icon(Fill::Solid(text_color)) .finish(); content = content.with_child( Container::new( ConstrainedBox::new(chevron_icon) .with_height(icon_size) .with_width(icon_size) .finish(), ) .with_margin_left(ICON_SPACING) .finish(), ); } let button = Container::new(content.finish()) .with_vertical_padding(vertical_padding) .with_horizontal_padding(horizontal_padding) .finish(); let button_with_save_position = SavePosition::new(button, "profile_model_selector_model_button").finish(); let hoverable = Hoverable::new(self.model_mouse_state.clone(), move |state| { if state.is_hovered() { let button_with_hover = Container::new(button_with_save_position) .with_background(theme.surface_2()) .with_corner_radius(CornerRadius::with_right(Radius::Pixels( INNER_CORNER_RADIUS, ))) .finish(); let tooltip_text = if !has_edit_access { "Request edit access to change model".to_owned() } else { "Choose an agent model".to_owned() }; let tooltip = appearance.ui_builder().tool_tip(tooltip_text); let mut stack = Stack::new(); stack.add_child(button_with_hover); stack.add_positioned_child( tooltip.build().finish(), OffsetPositioning::offset_from_parent( vec2f(0., -10.), ParentOffsetBounds::Unbounded, ParentAnchor::TopLeft, ChildAnchor::BottomLeft, ), ); stack.finish() } else { button_with_save_position } }); // Only make clickable if the user can click to open the menu (i.e. has edit access) if !has_edit_access { hoverable.finish() } else { hoverable .on_click(|ctx, _app, _position| { ctx.dispatch_typed_action(ProfileModelSelectorAction::ToggleModelMenu); }) .with_cursor(Cursor::PointingHand) .finish() } } fn render_separator(&self, app: &AppContext, visible: bool) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let separator_height = app.font_cache().line_height( appearance.monospace_font_size(), DEFAULT_UI_LINE_HEIGHT_RATIO / 1.4, ); let container = Container::new( ConstrainedBox::new(Empty::new().finish()) .with_width(SEPARATOR_WIDTH) .with_height(separator_height) .finish(), ); if visible { container .with_background(Fill::Solid(internal_colors::neutral_3(theme))) .finish() } else { // Invisible separator that maintains width to prevent flickering container.finish() } } fn render_model_spec_header( &self, title: String, description: String, app: &AppContext, ) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let title = Text::new(title, appearance.ui_font_family(), 14.) .with_color(internal_colors::neutral_7(theme)) .finish(); let description = Text::new( description, appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color(theme.disabled_ui_text_color().into()) .finish(); Container::new( Flex::column() .with_child(title) .with_child(Container::new(description).with_margin_top(4.).finish()) .finish(), ) .with_horizontal_margin(16.) .with_vertical_padding(16.) .with_border( Border::bottom(BORDER_WIDTH).with_border_color(internal_colors::neutral_3(theme)), ) .finish() } fn render_model_spec_value_label(&self, name: String, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); Container::new( ConstrainedBox::new( Text::new(name, appearance.ui_font_family(), 14.) .with_color(internal_colors::neutral_7(theme)) .finish(), ) .with_width(72.) .finish(), ) .with_margin_right(8.) .finish() } // Renders a single model spec value, including the label and the progress bar fn render_model_spec_value( &self, name: String, value: f32, bg_bar_color: ColorU, app: &AppContext, ) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let background_bar = Rect::new() .with_background_color(bg_bar_color) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(CORNER_RADIUS))) .finish(); let filled_bar = Rect::new() .with_background_color(internal_colors::neutral_6(theme)) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(CORNER_RADIUS))) .finish(); Container::new( Flex::row() .with_child(self.render_model_spec_value_label(name, app)) .with_child( Expanded::new( 1., ConstrainedBox::new( Stack::new() .with_child(background_bar) .with_child(Percentage::width(value, filled_bar).finish()) .finish(), ) .with_height(16.) .finish(), ) .finish(), ) .finish(), ) .with_margin_top(12.) .finish() } fn render_model_spec_api_key(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); Container::new( Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(self.render_model_spec_value_label("Cost".to_string(), app)) .with_child( Expanded::new( 1., Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( Container::new( Text::new( "Billed to API".to_string(), appearance.ui_font_family(), 14., ) .with_color(theme.disabled_ui_text_color().into()) .finish(), ) .finish(), ) .with_child(ChildView::new(&self.manage_api_key_button).finish()) .finish(), ) .finish(), ) .finish(), ) .with_margin_top(12.) .finish() } // Renders all model spec values for a given model spec fn render_all_model_spec_values( &self, spec: &LLMSpec, is_using_api_key: bool, bg_bar_color: ColorU, app: &AppContext, ) -> Box { let mut spec_values = vec![ self.render_model_spec_value( "Intelligence".to_string(), spec.quality, bg_bar_color, app, ), self.render_model_spec_value("Speed".to_string(), spec.speed, bg_bar_color, app), ]; if is_using_api_key { spec_values.push(self.render_model_spec_api_key(app)); } else { spec_values.push(self.render_model_spec_value( "Cost".to_string(), spec.cost, bg_bar_color, app, )); } Flex::column().with_children(spec_values).finish() } // Renders entire modal for a given model spec fn render_model_spec( &self, spec: &LLMSpec, is_using_api_key: bool, app: &AppContext, ) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let header = self.render_model_spec_header( "Model Specs".to_string(), "Warp’s benchmarks for how well a model performs in our harness, the rate at which it consumes credits, and task speed.".to_string(), app, ); let spec = self.render_all_model_spec_values( spec, is_using_api_key, internal_colors::neutral_3(theme), app, ); ConstrainedBox::new( Container::new( Flex::column() .with_child(header) .with_child(Container::new(spec).with_horizontal_padding(16.).finish()) .finish(), ) .with_padding_bottom(12.) .with_vertical_margin(8.) .with_background_color(internal_colors::neutral_2(theme)) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(CORNER_RADIUS))) .with_drop_shadow(DropShadow::default()) .finish(), ) .with_width(MENU_WIDTH) .finish() } fn render_sidecar_spec_panel( &self, kind: &ModelSpecSidecarKind, spec: &Option, app: &AppContext, ) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let (title, description) = match kind { ModelSpecSidecarKind::Auto => ( "Auto mode", "Auto will select the best model for the task. Cost-efficiency optimizes for cost, Responsiveness optimizes for response speed.", ), ModelSpecSidecarKind::Reasoning => ( "Reasoning level", "Increased reasoning levels consume more credits and have higher latency, but higher performance for complicated tasks.", ), }; let header = self.render_model_spec_header(title.to_string(), description.to_string(), app); let sidecar_menu = ChildView::new(&self.model_spec_sidecar.dropdown).finish(); let spec_values = self.render_all_model_spec_values( &spec.clone().unwrap_or_default(), false, internal_colors::neutral_5(theme), app, ); ConstrainedBox::new( Container::new( Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_child(header) .with_child(sidecar_menu) .with_child( Container::new(spec_values) .with_horizontal_margin(8.) .with_horizontal_padding(16.) .with_padding_top(4.) .with_padding_bottom(16.) .with_corner_radius(CornerRadius::with_all(Radius::Pixels( CORNER_RADIUS, ))) .with_background_color(internal_colors::neutral_3(theme)) .finish(), ) .finish(), ) .with_padding_bottom(8.) .with_background_color(internal_colors::neutral_2(theme)) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(CORNER_RADIUS))) .with_drop_shadow(DropShadow::default()) .finish(), ) .with_width(MENU_WIDTH) .finish() } } impl TypedActionView for ProfileModelSelector { type Action = ProfileModelSelectorAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { ProfileModelSelectorAction::SelectProfile(profile_id) => { AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles_model, ctx| { profiles_model.set_active_profile(self.terminal_view_id, *profile_id, ctx); }); // Remove any LLM override when switching profiles LLMPreferences::handle(ctx).update(ctx, |llm_prefs, ctx| { llm_prefs.remove_llm_override(self.terminal_view_id, ctx); }); self.set_profile_menu_visibility(false, ctx); } ProfileModelSelectorAction::SelectModel(llm_id) => { LLMPreferences::handle(ctx).update(ctx, |preferences, ctx| { log::info!("Selecting base agent model {llm_id} (from model selector)"); preferences.update_preferred_agent_mode_llm(llm_id, self.terminal_view_id, ctx); }); self.set_model_menu_visibility(false, ctx); } ProfileModelSelectorAction::SelectAutoModel | ProfileModelSelectorAction::SelectReasoningModel(_) => { self.handle_sidecar_selection(ctx); } ProfileModelSelectorAction::ManageProfiles => { self.set_profile_menu_visibility(false, ctx); ctx.emit(ProfileModelSelectorEvent::OpenSettings( SettingsSection::WarpAgent, )); } ProfileModelSelectorAction::ToggleProfileMenu => { self.set_profile_menu_visibility(!self.is_profile_menu_open, ctx); } ProfileModelSelectorAction::ToggleModelMenu => { if FeatureFlag::InlineMenuHeaders.is_enabled() { ctx.emit(ProfileModelSelectorEvent::ToggleInlineModelSelector); } else { self.set_model_menu_visibility(!self.is_model_menu_open, ctx); } } } } } impl View for ProfileModelSelector { fn ui_name() -> &'static str { "ProfileModelSelector" } fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let profiles_model = AIExecutionProfilesModel::as_ref(app); let has_multiple_profiles = profiles_model.has_multiple_profiles(); // Check if user is a viewer in a shared session let is_viewer = self .terminal_model .lock() .shared_session_status() .is_viewer(); let mut compact_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center); // Only add profile button to compact layout if there are multiple profiles // and the user is not a viewer (we currently don't support profiles in shared sessions). let should_show_profile_section = has_multiple_profiles && !is_viewer; if should_show_profile_section { let profile_button_with_save_position = SavePosition::new( ChildView::new(&self.profile_compact_button).finish(), "profile_model_selector_profile_compact_button", ) .finish(); compact_row.add_child(profile_button_with_save_position); } let model_button_with_save_position = SavePosition::new( ChildView::new(&self.model_compact_button).finish(), "profile_model_selector_model_compact_button", ) .finish(); compact_row.add_child(model_button_with_save_position); let compact_layout = compact_row.finish(); let mut chip_content = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center); // Only add profile section and separator if there are multiple profiles // and the user is not a viewer if should_show_profile_section { // Don't show separator if either selector is hovered let profile_hovered = self.profile_mouse_state.lock().unwrap().is_hovered(); let model_hovered = self.model_mouse_state.lock().unwrap().is_hovered(); let show_separator = !(profile_hovered || model_hovered); chip_content.add_child(self.render_profile_section(app)); chip_content.add_child(self.render_separator(app, show_separator)); } chip_content.add_child(self.render_model_section(app)); let unified_chip = Container::new( Container::new(chip_content.finish()) .with_background(theme.surface_1()) .with_border( Border::all(BORDER_WIDTH).with_border_color(internal_colors::neutral_3(theme)), ) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(CORNER_RADIUS))) .finish(), ) .finish(); let content = if self.render_compact { compact_layout } else { unified_chip }; let mut stack = Stack::new(); stack.add_child(content); if self.is_profile_menu_open && should_show_profile_section { let profile_menu = ChildView::new(&self.profile_dropdown).finish(); let positioning = self.get_menu_positioning(app, true); stack.add_positioned_overlay_child(profile_menu, positioning); } if self.is_model_menu_open { let model_menu = ChildView::new(&self.model_dropdown).finish(); let positioning = self.get_menu_positioning(app, false); stack.add_positioned_overlay_child(model_menu, positioning); if let Some(info) = self.hovered_llm_info.as_ref() { // Decide whether to show a sidecar purely from the hovered item let sidecar_kind = if is_auto(info) { Some(ModelSpecSidecarKind::Auto) } else if self.has_multiple_reasoning_variants(info) { Some(ModelSpecSidecarKind::Reasoning) } else { None }; let model_spec_sidecar = if let Some(kind) = sidecar_kind { // Show sidecar panel with default spec if none exists let sidecar_spec = self .model_spec_sidecar .hovered_info .as_ref() .and_then(|i| i.spec.as_ref()) .cloned(); Some(self.render_sidecar_spec_panel(&kind, &sidecar_spec, app)) } else if let Some(spec) = info.spec.as_ref() { let is_using_api_key = is_using_api_key_for_provider(&info.provider, app); Some(self.render_model_spec(spec, is_using_api_key, app)) } else { None }; if let Some(model_spec_sidecar) = model_spec_sidecar { let position_id = self.model_menu_item_position_id(&info.id); let flip_left = self.should_render_model_sidecar_left(&position_id, app); let offset_x = if flip_left { -(MENU_WIDTH + SIDECAR_HORIZONTAL_GAP) } else { MENU_WIDTH + SIDECAR_HORIZONTAL_GAP }; let sidecar_with_position = SavePosition::new(model_spec_sidecar, SIDECAR_POSITION_ID).finish(); stack.add_positioned_overlay_child( sidecar_with_position, OffsetPositioning::offset_from_save_position_element( position_id, vec2f(offset_x, 0.), PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::Center, ChildAnchor::Center, ), ); } } } let is_udi_enabled = crate::settings::InputSettings::as_ref(app).is_universal_developer_input_enabled(app); if is_udi_enabled || self .input_model .as_ref(app) .last_ai_autodetection_ts() .is_none_or(|ts| Instant::now().duration_since(ts) > NEW_MODEL_CHOICES_POPUP_DELAY) { let llm_preferences = LLMPreferences::as_ref(app); match ( llm_preferences.should_show_new_choices_popup(self.terminal_view_id), llm_preferences.new_choices_since_last_update(), ) { (true, Some(new_choices)) if !new_choices.is_empty() => { llm_preferences.mark_new_choices_popup_as_shown(self.terminal_view_id); stack.add_positioned_overlay_child( ChildView::new(&self.new_model_popup).finish(), // Render the popup above the chip, centered horizontally. OffsetPositioning::offset_from_parent( vec2f(0., -6.), ParentOffsetBounds::WindowByPosition, ParentAnchor::TopMiddle, ChildAnchor::BottomMiddle, ), ); } _ => (), } } stack.finish() } } impl Entity for ProfileModelSelector { type Event = ProfileModelSelectorEvent; }