//! Harness selector: an "options menu" (`ActionButton` + generic `Menu`) shown //! in a row above the cloud mode input that lets the user switch between the Oz //! and Claude Code harnesses. use std::sync::Arc; use pathfinder_geometry::vector::vec2f; use warpui::{ elements::{ Border, ChildAnchor, ChildView, OffsetPositioning, ParentAnchor, ParentElement as _, ParentOffsetBounds, Stack, }, AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, }; use warp_cli::agent::Harness; use warp_core::ui::appearance::Appearance; use warp_core::ui::theme::color::internal_colors; use warp_core::ui::theme::Fill; use crate::ai::blocklist::agent_view::agent_input_footer::AgentInputButtonTheme; use crate::ai::harness_display::{display_name, icon_for}; use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields}; use crate::terminal::input::{MenuPositioning, MenuPositioningProvider}; use crate::terminal::view::ambient_agent::AmbientAgentViewModel; use crate::view_components::action_button::{ActionButton, ActionButtonTheme, ButtonSize}; /// Font size for the header row (Figma: 12px). const HEADER_FONT_SIZE: f32 = 12.; /// Font size for harness item rows (Figma: 14px). const ITEM_FONT_SIZE: f32 = 14.; /// Horizontal padding inside menu rows (Figma: 16px). const MENU_HORIZONTAL_PADDING: f32 = 16.; /// Vertical padding on harness rows (Figma: 8px top & bottom). const ITEM_VERTICAL_PADDING: f32 = 8.; /// Vertical padding on the header row. Figma uses asymmetric `8px top / 4px /// bottom`; `MenuItemFields::with_padding_override` only supports a single /// vertical value, so we approximate with the average (6px). const HEADER_VERTICAL_PADDING: f32 = 6.; /// Width of the dropdown panel in logical pixels. const MENU_WIDTH: f32 = 208.; /// Leading-icon size for harness item rows in logical pixels. Slightly larger /// than the default `ui_font_size()` to give the logos more visual presence. const ITEM_ICON_SIZE: f32 = 16.; /// Tooltip string for the closed-state button. const BUTTON_TOOLTIP: &str = "Agent harness"; /// Label rendered at the top of the dropdown. const MENU_HEADER_LABEL: &str = "Agent harness"; /// Actions dispatched by the [`HarnessSelector`]. #[derive(Clone, Debug, PartialEq)] pub enum HarnessSelectorAction { /// Toggle the visibility of the dropdown menu. ToggleMenu, /// The user picked a harness from the dropdown. SelectHarness(Harness), } /// Events emitted by the [`HarnessSelector`]. pub enum HarnessSelectorEvent { /// The dropdown visibility changed. MenuVisibilityChanged { open: bool }, } /// A dropdown selector for choosing which execution harness to run cloud agent /// prompts with. pub struct HarnessSelector { button: ViewHandle, menu: ViewHandle>, is_menu_open: bool, menu_positioning_provider: Arc, ambient_agent_model: ModelHandle, } impl HarnessSelector { pub fn new( menu_positioning_provider: Arc, ambient_agent_model: ModelHandle, ctx: &mut ViewContext, ) -> Self { let button = ctx.add_typed_action_view(|_ctx| { ActionButton::new("", AgentInputButtonTheme) .with_size(ButtonSize::AgentInputButton) .with_menu(true) .with_tooltip(BUTTON_TOOLTIP) .on_click(|ctx| { ctx.dispatch_typed_action(HarnessSelectorAction::ToggleMenu); }) }); let menu = ctx.add_typed_action_view(|_ctx| { Menu::new() .with_width(MENU_WIDTH) .with_drop_shadow() .prevent_interaction_with_other_elements() }); ctx.subscribe_to_view(&menu, |me, _, event, ctx| match event { MenuEvent::Close { .. } => { me.set_menu_visibility(false, ctx); } MenuEvent::ItemSelected | MenuEvent::ItemHovered => {} }); ctx.subscribe_to_model(&ambient_agent_model, |me, _, _, ctx| { me.refresh_button(ctx); me.refresh_menu(ctx); }); ctx.subscribe_to_model(&Appearance::handle(ctx), |me, _, _, ctx| { me.refresh_menu(ctx); }); let mut me = Self { button, menu, is_menu_open: false, menu_positioning_provider, ambient_agent_model, }; me.refresh_button(ctx); me.refresh_menu(ctx); me } pub fn is_menu_open(&self) -> bool { self.is_menu_open } pub fn set_button_theme( &self, theme: impl ActionButtonTheme + 'static, ctx: &mut ViewContext, ) { self.button.update(ctx, |button, ctx| { button.set_theme(theme, ctx); }); } fn set_menu_visibility(&mut self, is_open: bool, ctx: &mut ViewContext) { if self.is_menu_open == is_open { return; } self.is_menu_open = is_open; if is_open { ctx.focus(&self.menu); } ctx.emit(HarnessSelectorEvent::MenuVisibilityChanged { open: is_open }); ctx.notify(); } fn refresh_button(&mut self, ctx: &mut ViewContext) { let harness = self.ambient_agent_model.as_ref(ctx).selected_harness(); let label = display_name(harness).to_string(); let icon = icon_for(harness); self.button.update(ctx, |button, ctx| { button.set_label(label, ctx); button.set_icon(Some(icon), ctx); }); } fn refresh_menu(&mut self, ctx: &mut ViewContext) { let appearance = Appearance::as_ref(ctx); let theme = appearance.theme(); let hover_background: Fill = internal_colors::neutral_4(theme).into(); let header_text_color = theme.disabled_text_color(theme.surface_2()).into_solid(); let border = Border::all(1.).with_border_fill(theme.outline()); let items = build_menu_items(hover_background, header_text_color); self.menu.update(ctx, |menu, ctx| { menu.set_border(Some(border)); menu.set_items(items, ctx); }); } fn menu_positioning(&self, app: &AppContext) -> OffsetPositioning { match self.menu_positioning_provider.menu_position(app) { MenuPositioning::BelowInputBox => OffsetPositioning::offset_from_parent( vec2f(0., 4.), ParentOffsetBounds::WindowByPosition, ParentAnchor::BottomLeft, ChildAnchor::TopLeft, ), MenuPositioning::AboveInputBox => OffsetPositioning::offset_from_parent( vec2f(0., -4.), ParentOffsetBounds::WindowByPosition, ParentAnchor::TopLeft, ChildAnchor::BottomLeft, ), } } } /// Builds the menu items for the harness selector. The first item is a /// non-clickable header; the remaining items are the available harnesses. /// Item text colors are left as theme defaults; the header uses the theme's /// disabled-text color, and hovered/selected rows use `neutral_4`. fn build_menu_items( hover_background: Fill, header_text_color: pathfinder_color::ColorU, ) -> Vec> { let header = MenuItem::Header { fields: MenuItemFields::new(MENU_HEADER_LABEL) .with_font_size_override(HEADER_FONT_SIZE) .with_override_text_color(header_text_color) .with_padding_override(HEADER_VERTICAL_PADDING, MENU_HORIZONTAL_PADDING) .with_no_interaction_on_hover(), clickable: false, right_side_fields: None, }; let item_for = |harness: Harness| { MenuItem::Item( MenuItemFields::new(display_name(harness)) .with_icon(icon_for(harness)) .with_icon_size_override(ITEM_ICON_SIZE) .with_font_size_override(ITEM_FONT_SIZE) .with_padding_override(ITEM_VERTICAL_PADDING, MENU_HORIZONTAL_PADDING) .with_override_hover_background_color(hover_background) .with_on_select_action(HarnessSelectorAction::SelectHarness(harness)), ) }; vec![ header, item_for(Harness::Oz), item_for(Harness::Claude), item_for(Harness::Gemini), ] } impl Entity for HarnessSelector { type Event = HarnessSelectorEvent; } impl TypedActionView for HarnessSelector { type Action = HarnessSelectorAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { HarnessSelectorAction::ToggleMenu => { let new_state = !self.is_menu_open; self.set_menu_visibility(new_state, ctx); } HarnessSelectorAction::SelectHarness(harness) => { let harness = *harness; self.ambient_agent_model.update(ctx, |model, ctx| { model.set_harness(harness, ctx); }); self.set_menu_visibility(false, ctx); } } } } impl View for HarnessSelector { fn ui_name() -> &'static str { "HarnessSelector" } fn render(&self, app: &AppContext) -> Box { let mut stack = Stack::new(); stack.add_child(ChildView::new(&self.button).finish()); if self.is_menu_open { let positioning = self.menu_positioning(app); stack.add_positioned_overlay_child(ChildView::new(&self.menu).finish(), positioning); } stack.finish() } }