diff --git a/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs b/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs index f7b0dff326..527e893d45 100644 --- a/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs +++ b/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs @@ -2023,20 +2023,7 @@ impl AgentInputFooter { AgentToolbarItemKind::ModelSelector => { let show = FeatureFlag::ProfilesDesignRevamp.is_enabled() || *SessionSettings::as_ref(app).show_model_selectors_in_prompt; - if !show { - return None; - } - let is_ambient_agent = self - .ambient_agent_view_model - .as_ref() - .is_some_and(|m| m.as_ref(app).is_ambient_agent()); - if is_ambient_agent { - self.v2_model_selector - .as_ref() - .map(|selector| ChildView::new(selector).finish()) - } else { - Some(ChildView::new(&self.model_selector).finish()) - } + show.then(|| ChildView::new(&self.model_selector).finish()) } AgentToolbarItemKind::NLDToggle => Some(ChildView::new(&self.nld_button).finish()), AgentToolbarItemKind::VoiceInput => { diff --git a/app/src/ai/cloud_agent_settings.rs b/app/src/ai/cloud_agent_settings.rs index 761e1594ab..ba792dd0bd 100644 --- a/app/src/ai/cloud_agent_settings.rs +++ b/app/src/ai/cloud_agent_settings.rs @@ -7,6 +7,7 @@ use std::collections::HashMap; use settings::{macros::define_settings_group, Setting as _, SupportedPlatforms, SyncToCloud}; use warp_cli::agent::Harness; +use warp_core::report_if_error; use crate::server::ids::SyncId; @@ -89,4 +90,27 @@ impl CloudAgentSettings { map.insert(harness.config_name().to_string(), true); let _ = self.harness_auth_ftux_completed.set_value(map, ctx); } + + /// Persists (or clears) the harness model selection for the given harness. + pub fn persist_harness_model_selection( + &mut self, + harness: Harness, + model_id: &str, + reasoning_level: Option, + ctx: &mut warpui::ModelContext, + ) { + let mut map = self.last_selected_harness_model.value().clone(); + if model_id.is_empty() { + map.remove(harness.config_name()); + } else { + map.insert( + harness.config_name().to_string(), + HarnessModelSelection { + model_id: model_id.to_string(), + reasoning_level, + }, + ); + } + report_if_error!(self.last_selected_harness_model.set_value(map, ctx)); + } } diff --git a/app/src/terminal/profile_model_selector.rs b/app/src/terminal/profile_model_selector.rs index 2ec4474ccb..a4fc1ec6f7 100644 --- a/app/src/terminal/profile_model_selector.rs +++ b/app/src/terminal/profile_model_selector.rs @@ -23,16 +23,22 @@ use warpui::{ const SIDECAR_HORIZONTAL_GAP: f32 = 8.; const SIDECAR_POSITION_ID: &str = "model_sidecar_panel"; +use warp_cli::agent::Harness; + use crate::{ ai::{ blocklist::{ prompt::PromptIconButtonTheme, BlocklistAIController, BlocklistAIControllerEvent, BlocklistAIInputEvent, BlocklistAIInputModel, }, + cloud_agent_settings::CloudAgentSettings, execution_profiles::{ model_menu_items::{available_model_menu_items, has_reasoning_variants, is_auto}, profiles::{AIExecutionProfilesModel, AIExecutionProfilesModelEvent, ClientProfileId}, }, + harness_availability::{ + HarnessAvailabilityEvent, HarnessAvailabilityModel, HarnessModelInfo, + }, llms::{ dedupe_model_display_names, is_using_api_key_for_provider, LLMId, LLMInfo, LLMPreferences, LLMPreferencesEvent, LLMSpec, @@ -86,6 +92,7 @@ const PROFILE_PICKER_TOOLTIP: &str = "Choose an AI execution profile"; const MODEL_PICKER_TOOLTIP: &str = "Choose an agent model"; const MODEL_LOCKED_FOR_FOLLOWUP_TOOLTIP: &str = "Follow-ups use the original run's model"; const MODEL_REQUIRES_EDIT_ACCESS_TOOLTIP: &str = "Request edit access to change model"; +const HARNESS_DEFAULT_MODEL_LABEL: &str = "default"; pub fn calculate_scaled_font_size(appearance: &warp_core::ui::appearance::Appearance) -> f32 { if FeatureFlag::AgentView.is_enabled() { @@ -196,6 +203,10 @@ pub enum ProfileModelSelectorAction { SelectModel(LLMId), SelectAutoModel, SelectReasoningModel(String), + SelectHarnessModel { + model_id: String, + reasoning_level: Option, + }, ManageProfiles, ToggleProfileMenu, ToggleModelMenu, @@ -515,7 +526,9 @@ impl ProfileModelSelector { use crate::terminal::view::ambient_agent::AmbientAgentViewModelEvent; if matches!( event, - AmbientAgentViewModelEvent::RunLifecycleChanged + AmbientAgentViewModelEvent::HarnessSelected + | AmbientAgentViewModelEvent::HarnessModelSelected + | AmbientAgentViewModelEvent::RunLifecycleChanged | AmbientAgentViewModelEvent::SessionReady { .. } | AmbientAgentViewModelEvent::FollowupDispatched ) { @@ -524,6 +537,15 @@ impl ProfileModelSelector { }); } + ctx.subscribe_to_model( + &HarnessAvailabilityModel::handle(ctx), + |me, _, event, ctx| { + if let HarnessAvailabilityEvent::Changed = event { + me.refresh_state(ctx); + } + }, + ); + let manage_api_key_button = ctx.add_typed_action_view(|_ctx| { ActionButton::new("Manage", SecondaryTheme) .with_tooltip("Manage API keys") @@ -632,10 +654,7 @@ impl ProfileModelSelector { self.ambient_agent_view_model.as_ref().is_some_and(|m| { let model = m.as_ref(app); model.task_id().is_some() - && !matches!( - model.selected_harness(), - warp_cli::agent::Harness::Oz | warp_cli::agent::Harness::Unknown - ) + && !matches!(model.selected_harness(), Harness::Oz | Harness::Unknown) }) } @@ -643,6 +662,14 @@ impl ProfileModelSelector { self.is_locked_for_cloud_followup(app) || self.is_locked_for_non_oz_run(app) } + /// True when a non-Oz harness is selected. + fn is_third_party_harness(&self, app: &AppContext) -> bool { + self.ambient_agent_view_model.as_ref().is_some_and(|m| { + let model = m.as_ref(app); + !matches!(model.selected_harness(), Harness::Oz | Harness::Unknown) + }) + } + fn refresh_state(&mut self, ctx: &mut ViewContext) { self.refresh_profile_menu(ctx); self.refresh_model_menu(ctx); @@ -660,7 +687,9 @@ impl ProfileModelSelector { }); } - let model_name = { + let model_name = if self.is_third_party_harness(ctx) { + self.harness_model_display_name(ctx) + } else { let llm_preferences = LLMPreferences::as_ref(ctx); let active_llm = if FeatureFlag::InlineMenuHeaders.is_enabled() && self @@ -834,7 +863,103 @@ impl ProfileModelSelector { }); } + // Checks that we have a harness in the `AmbientAgentViewModel` and returns model options from + // the `HarnessAvailabilityModel` for that harness. + fn active_harness_model_info<'a>(&self, app: &'a AppContext) -> Option<&'a HarnessModelInfo> { + let ambient_model = self.ambient_agent_view_model.as_ref()?.as_ref(app); + let harness = ambient_model.selected_harness(); + let model_id = ambient_model.selected_harness_model_id()?; + let reasoning_level = ambient_model.selected_harness_reasoning_level(); + HarnessAvailabilityModel::as_ref(app) + .models_for(harness)? + .iter() + .find(|m| m.id == model_id && m.reasoning_level.as_deref() == reasoning_level) + } + + fn harness_model_display_name(&self, app: &AppContext) -> String { + self.active_harness_model_info(app) + .map(|info| info.display_name.clone()) + .unwrap_or_else(|| HARNESS_DEFAULT_MODEL_LABEL.to_string()) + } + + fn refresh_harness_model_menu(&mut self, ctx: &mut ViewContext) { + let ambient_model = match self.ambient_agent_view_model.as_ref() { + Some(m) => m, + None => return, + }; + let harness = ambient_model.as_ref(ctx).selected_harness(); + let selected_model_id = ambient_model + .as_ref(ctx) + .selected_harness_model_id() + .map(str::to_owned); + let selected_reasoning = ambient_model + .as_ref(ctx) + .selected_harness_reasoning_level() + .map(str::to_owned); + + let models = HarnessAvailabilityModel::as_ref(ctx).models_for(harness); + + let mut items: Vec> = Vec::new(); + + let default_selected = selected_model_id.is_none(); + let default_action = ProfileModelSelectorAction::SelectHarnessModel { + model_id: String::new(), + reasoning_level: None, + }; + let mut default_fields = + MenuItemFields::new(HARNESS_DEFAULT_MODEL_LABEL).with_on_select_action(default_action); + if default_selected { + default_fields = default_fields.with_icon(Icon::Check); + } else { + default_fields = default_fields.with_indent(); + } + items.push(MenuItem::Item(default_fields)); + + if let Some(models) = models { + for model in models { + let is_selected = selected_model_id.as_deref() == Some(&model.id) + && selected_reasoning.as_deref() == model.reasoning_level.as_deref(); + let mut fields = MenuItemFields::new(model.display_name.clone()) + .with_on_select_action(ProfileModelSelectorAction::SelectHarnessModel { + model_id: model.id.clone(), + reasoning_level: model.reasoning_level.clone(), + }); + if is_selected { + fields = fields.with_icon(Icon::Check); + } else { + fields = fields.with_indent(); + } + items.push(MenuItem::Item(fields)); + } + } + + let selected_index = items + .iter() + .position(|item| { + matches!( + item.item_on_select_action(), + Some(ProfileModelSelectorAction::SelectHarnessModel { model_id, reasoning_level }) + if (model_id.is_empty() && default_selected) + || (selected_model_id.as_deref() == Some(model_id.as_str()) + && selected_reasoning.as_deref() == reasoning_level.as_deref()) + ) + }) + .unwrap_or(0); + + 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(); + }); + } + fn refresh_model_menu(&mut self, ctx: &mut ViewContext) { + if self.is_third_party_harness(ctx) { + self.refresh_harness_model_menu(ctx); + return; + } + let llm_preferences = LLMPreferences::as_ref(ctx); let active_llm = llm_preferences.get_active_base_model(ctx, Some(self.terminal_view_id)); @@ -1533,7 +1658,9 @@ impl ProfileModelSelector { .is_agent_in_control_or_tagged_in(); drop(terminal_model); - let model_display_name = if is_lrc { + let model_display_name = if self.is_third_party_harness(app) { + self.harness_model_display_name(app) + } else if is_lrc { llm_preferences .get_active_cli_agent_model(app, Some(self.terminal_view_id)) .menu_display_name() @@ -2001,6 +2128,35 @@ impl TypedActionView for ProfileModelSelector { | ProfileModelSelectorAction::SelectReasoningModel(_) => { self.handle_sidecar_selection(ctx); } + ProfileModelSelectorAction::SelectHarnessModel { + model_id, + reasoning_level, + } => { + let is_default = model_id.is_empty(); + if let Some(ambient_agent_model) = self.ambient_agent_view_model.clone() { + ambient_agent_model.update(ctx, |model, ctx| { + model.set_harness_model_selection( + (!is_default).then(|| model_id.clone()), + if is_default { + None + } else { + reasoning_level.clone() + }, + ctx, + ); + }); + let harness = ambient_agent_model.as_ref(ctx).selected_harness(); + CloudAgentSettings::handle(ctx).update(ctx, |settings, ctx| { + settings.persist_harness_model_selection( + harness, + model_id, + reasoning_level.clone(), + ctx, + ); + }); + } + self.set_model_menu_visibility(false, ctx); + } ProfileModelSelectorAction::ManageProfiles => { self.set_profile_menu_visibility(false, ctx); ctx.emit(ProfileModelSelectorEvent::OpenSettings( @@ -2014,7 +2170,9 @@ impl TypedActionView for ProfileModelSelector { if self.is_model_locked(ctx) { return; } - if FeatureFlag::InlineMenuHeaders.is_enabled() { + if self.is_third_party_harness(ctx) { + self.set_model_menu_visibility(!self.is_model_menu_open, ctx); + } else if FeatureFlag::InlineMenuHeaders.is_enabled() { ctx.emit(ProfileModelSelectorEvent::ToggleInlineModelSelector); } else { self.set_model_menu_visibility(!self.is_model_menu_open, ctx); @@ -2046,7 +2204,8 @@ impl View for ProfileModelSelector { // 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; + let is_ambient_agent = self.ambient_agent_view_model.is_some(); + let should_show_profile_section = has_multiple_profiles && !is_viewer && !is_ambient_agent; if should_show_profile_section { let profile_button_with_save_position = SavePosition::new( ChildView::new(&self.profile_compact_button).finish(), diff --git a/app/src/terminal/view/ambient_agent/model_selector.rs b/app/src/terminal/view/ambient_agent/model_selector.rs index a3e98f1411..b48adf3543 100644 --- a/app/src/terminal/view/ambient_agent/model_selector.rs +++ b/app/src/terminal/view/ambient_agent/model_selector.rs @@ -18,7 +18,7 @@ use warp_core::ui::theme::Fill; use settings::Setting as _; use crate::ai::blocklist::agent_view::agent_input_footer::AgentInputButtonTheme; -use crate::ai::cloud_agent_settings::{CloudAgentSettings, HarnessModelSelection}; +use crate::ai::cloud_agent_settings::CloudAgentSettings; use crate::ai::execution_profiles::model_menu_items::is_auto; use crate::ai::harness_availability::{HarnessAvailabilityEvent, HarnessAvailabilityModel}; use crate::ai::harness_display::icon_for as harness_icon_for; @@ -28,7 +28,6 @@ use crate::editor::{ SingleLineEditorOptions, TextOptions, }; use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields, MenuVariant}; -use crate::report_if_error; use crate::terminal::input::{MenuPositioning, MenuPositioningProvider}; use crate::terminal::view::ambient_agent::{AmbientAgentViewModel, AmbientAgentViewModelEvent}; use crate::ui_components::icons::Icon; @@ -667,19 +666,12 @@ impl TypedActionView for ModelSelector { } // Persist the selection per-harness to settings for next time. CloudAgentSettings::handle(ctx).update(ctx, |settings, ctx| { - let mut map = settings.last_selected_harness_model.value().clone(); - if is_default { - map.remove(harness.config_name()); - } else { - map.insert( - harness.config_name().to_string(), - HarnessModelSelection { - model_id: model_id.clone(), - reasoning_level: reasoning_level.clone(), - }, - ); - } - report_if_error!(settings.last_selected_harness_model.set_value(map, ctx)); + settings.persist_harness_model_selection( + *harness, + model_id, + reasoning_level.clone(), + ctx, + ); }); self.set_menu_visibility(false, ctx); self.refresh_button(ctx);