Files
galaxy/app/src/terminal/profile_model_selector.rs
T

2024 lines
78 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Fill> {
let theme = appearance.theme();
Some(if hovered {
theme.surface_2()
} else {
theme.surface_1()
})
}
fn text_color(
&self,
_hovered: bool,
_background: Option<Fill>,
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<warpui::fonts::Properties> {
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<ActionButton>,
model_button: ViewHandle<ActionButton>,
profile_compact_button: ViewHandle<ActionButton>,
model_compact_button: ViewHandle<ActionButton>,
profile_dropdown: ViewHandle<Menu<ProfileModelSelectorAction>>,
model_dropdown: ViewHandle<Menu<ProfileModelSelectorAction>>,
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<dyn MenuPositioningProvider>,
is_blurred: bool,
new_model_popup: ViewHandle<FeaturePopup>,
input_model: ModelHandle<BlocklistAIInputModel>,
ambient_agent_view_model: ModelHandle<AmbientAgentViewModel>,
render_compact: bool,
hovered_llm_info: Option<LLMInfo>,
manage_api_key_button: ViewHandle<ActionButton>,
terminal_model: Arc<FairMutex<TerminalModel>>,
all_model_choices: Vec<LLMInfo>,
}
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<Menu<ProfileModelSelectorAction>>,
hovered_info: Option<LLMInfo>,
active_kind: Option<ModelSpecSidecarKind>,
}
impl ProfileModelSelectorAction {
pub fn selected_model_id(&self) -> Option<LLMId> {
match self {
ProfileModelSelectorAction::SelectModel(id) => Some(id.clone()),
_ => None,
}
}
}
impl ProfileModelSelector {
pub fn new(
menu_positioning_provider: Arc<dyn crate::terminal::input::MenuPositioningProvider>,
terminal_view_id: EntityId,
input_model: ModelHandle<BlocklistAIInputModel>,
ambient_agent_view_model: ModelHandle<AmbientAgentViewModel>,
terminal_model: Arc<FairMutex<TerminalModel>>,
controller: Option<ModelHandle<BlocklistAIController>>,
ctx: &mut ViewContext<Self>,
) -> 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::<Vec<_>>()
.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<Self>) {
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<Self>) {
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>) {
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>) {
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<Self>) {
if self.render_compact != render_compact {
self.render_compact = render_compact;
ctx.notify();
}
}
fn handle_appearance_change(&mut self, ctx: &mut ViewContext<Self>) {
self.update_chip_themes(ctx);
self.update_compact_button_themes(ctx);
}
fn update_chip_themes(&self, ctx: &mut ViewContext<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<String, Vec<&LLMInfo>> = 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<Self>,
) {
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<MenuItem<ProfileModelSelectorAction>> = 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 (<sub-variant>)"
// 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<Self>,
) {
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<MenuItem<ProfileModelSelectorAction>> = 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<ProfileModelSelectorAction> {
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<ProfileModelSelectorAction>],
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<Self>) {
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<ProfileModelSelectorAction>],
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<Self>,
) -> Option<LLMInfo> {
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<usize>, ctx: &mut ViewContext<Self>) {
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<usize>, ctx: &mut ViewContext<Self>) {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let header = self.render_model_spec_header(
"Model Specs".to_string(),
"Warps 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<LLMSpec>,
app: &AppContext,
) -> Box<dyn Element> {
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<Self>) {
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<dyn Element> {
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;
}