Files
galaxy/crates/onboarding/src/slides/agent_slide.rs
T

1098 lines
41 KiB
Rust

use ai::LLMId;
use galaxy_core::ui::icons::Icon;
use pathfinder_color::ColorU;
use ui_components::{button, Component as _, Options as _};
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::Fill;
use galaxyui_core::elements::{
AnchorPair, Border, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,
CornerRadius, CrossAxisAlignment, Dismiss, Empty, Flex, FormattedTextElement, Hoverable,
Icon as WarpUiIcon, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning,
OffsetType, ParentElement, ParentOffsetBounds, PositioningAxis, Radius, SavePosition,
ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Stack, Text, XAxisAnchor, YAxisAnchor,
};
use galaxyui_core::fonts::{Properties, Weight};
use galaxyui_core::keymap::Keystroke;
use galaxyui_core::platform::Cursor;
use galaxyui_core::scene::DropShadow;
use galaxyui_core::text_layout::TextAlignment;
use galaxyui_core::ui_components::components::{UiComponent as _, UiComponentStyles};
use galaxyui_core::{
AppContext, Element, Entity, SingletonEntity as _, TypedActionView, View, ViewContext,
};
use super::two_line_button::{render_two_line_button, TwoLineButtonSpec};
use super::OnboardingSlide;
use crate::model::{OnboardingStateEvent, OnboardingStateModel};
use crate::slides::{bottom_nav, layout, slide_content};
use crate::visuals::agent_visual;
/// Information about a model displayed on the onboarding slide.
#[derive(Clone, Debug)]
pub struct OnboardingModelInfo {
pub id: LLMId,
pub title: String,
pub icon: Icon,
pub is_default: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum AgentAutonomy {
Full,
#[default]
Partial,
None,
}
impl std::fmt::Display for AgentAutonomy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AgentAutonomy::Full => write!(f, "full"),
AgentAutonomy::Partial => write!(f, "partial"),
AgentAutonomy::None => write!(f, "none"),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AgentDevelopmentSettings {
/// The selected model's ID.
pub selected_model_id: LLMId,
pub autonomy: Option<AgentAutonomy>,
/// Whether the CLI agent toolbar is enabled (maps to `should_render_cli_agent_footer`).
pub cli_agent_toolbar_enabled: bool,
/// The default session mode chosen during onboarding.
pub session_default: crate::SessionDefault,
/// Whether the user chose to disable the Oz AI assistant.
pub disable_oz: bool,
/// Whether agent notifications (mailbox button, toasts, notification items) are shown.
pub show_agent_notifications: bool,
}
impl AgentDevelopmentSettings {
pub fn new(default_model_id: LLMId) -> Self {
Self {
selected_model_id: default_model_id,
autonomy: Some(AgentAutonomy::default()),
cli_agent_toolbar_enabled: true,
session_default: crate::SessionDefault::Agent,
disable_oz: false,
show_agent_notifications: true,
}
}
}
#[derive(Debug, Clone)]
pub enum AgentSlideAction {
/// Select model by its ID. When the picker is expanded this also collapses it.
SelectModel(LLMId),
/// Toggle the expanded state of the collapsed picker chip.
ToggleModelListExpanded,
/// Update the keyboard/hover highlight cursor to the given model id.
/// Dispatched from hover handlers on enabled rows.
HighlightModel(LLMId),
SelectAutonomy(AgentAutonomy),
ToggleDisableOz,
BackClicked,
NextClicked,
}
pub struct AgentSlide {
onboarding_state: galaxyui_core::ModelHandle<OnboardingStateModel>,
/// Mouse state handles for each model row.
model_mouse_states: Vec<MouseStateHandle>,
/// Mouse state handle for the collapsed model-picker chip (closed-state click target).
chip_mouse_state: MouseStateHandle,
autonomy_full_mouse_state: MouseStateHandle,
autonomy_partial_mouse_state: MouseStateHandle,
autonomy_none_mouse_state: MouseStateHandle,
back_button: button::Button,
next_button: button::Button,
scroll_state: ClippedScrollStateHandle,
dropdown_scroll_state: ClippedScrollStateHandle,
is_model_list_expanded: bool,
highlighted_model_id: Option<LLMId>,
}
/// Produces the `SavePosition` id for the model row at `index` in the
/// dropdown list. Used by `scroll_to_position` to scroll a specific row into
/// view as the keyboard highlight moves.
fn model_row_position_id(index: usize) -> String {
format!("agent_slide_model_row_{index}")
}
impl AgentSlide {
pub(crate) fn new(
onboarding_state: galaxyui_core::ModelHandle<OnboardingStateModel>,
ctx: &mut ViewContext<Self>,
) -> Self {
let model_count = onboarding_state.as_ref(ctx).models().len();
let model_mouse_states = (0..model_count)
.map(|_| MouseStateHandle::default())
.collect();
ctx.subscribe_to_model(&onboarding_state, |me, model, event, ctx| {
match event {
OnboardingStateEvent::ModelsUpdated => {
let state = model.as_ref(ctx);
// Clear the highlight if its id is no longer in the list.
let still_present = me
.highlighted_model_id
.as_ref()
.is_some_and(|id| state.models().iter().any(|m| &m.id == id));
if !still_present {
me.highlighted_model_id = None;
}
let model_count = state.models().len();
me.ensure_mouse_states_for_models(model_count, ctx);
}
OnboardingStateEvent::AuthStateChanged
| OnboardingStateEvent::SelectedSlideChanged
| OnboardingStateEvent::IntentionChanged
| OnboardingStateEvent::Completed
| OnboardingStateEvent::UpgradeRequested
| OnboardingStateEvent::NoAiConfirmationChanged => {}
}
});
Self {
onboarding_state,
model_mouse_states,
chip_mouse_state: MouseStateHandle::default(),
autonomy_full_mouse_state: MouseStateHandle::default(),
autonomy_partial_mouse_state: MouseStateHandle::default(),
autonomy_none_mouse_state: MouseStateHandle::default(),
back_button: button::Button::default(),
next_button: button::Button::default(),
scroll_state: ClippedScrollStateHandle::new(),
dropdown_scroll_state: ClippedScrollStateHandle::new(),
is_model_list_expanded: false,
highlighted_model_id: None,
}
}
/// Ensures we have enough mouse state handles for the given model count.
fn ensure_mouse_states_for_models(&mut self, model_count: usize, ctx: &mut ViewContext<Self>) {
if self.model_mouse_states.len() < model_count {
self.model_mouse_states.extend(
(self.model_mouse_states.len()..model_count).map(|_| MouseStateHandle::default()),
);
}
ctx.notify();
}
fn agent_settings<'a>(&self, app: &'a AppContext) -> &'a AgentDevelopmentSettings {
self.onboarding_state.as_ref(app).agent_settings()
}
fn workspace_enforces_autonomy(&self, app: &AppContext) -> bool {
self.onboarding_state
.as_ref(app)
.workspace_enforces_autonomy()
}
fn render_content(
&self,
appearance: &Appearance,
settings: &AgentDevelopmentSettings,
workspace_enforces_autonomy: bool,
app: &AppContext,
) -> Box<dyn Element> {
// The collapsed slide content is rendered unconditionally — the expanded
// state is a floating overlay (built in `View::render`) that sits *on top
// of* this content, so the underlying layout never shifts between the two
// states. That keeps the header + picker chip pinned in place.
let bottom_nav = self.render_bottom_nav(appearance, app);
slide_content::onboarding_slide_content(
vec![
self.render_header(appearance),
self.render_sections(appearance, settings, workspace_enforces_autonomy, app),
],
bottom_nav,
self.scroll_state.clone(),
appearance,
)
}
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
let title = appearance
.ui_builder()
.paragraph("Customize your Warp Agent")
.with_style(UiComponentStyles {
font_size: Some(36.),
font_weight: Some(Weight::Medium),
..Default::default()
})
.build()
.finish();
let subtitle = FormattedTextElement::from_str(
"Select your Warp Agent's defaults.",
appearance.ui_font_family(),
16.,
)
.with_color(internal_colors::text_sub(
appearance.theme(),
appearance.theme().background().into_solid(),
))
.with_weight(Weight::Normal)
.with_alignment(TextAlignment::Left)
.with_line_height_ratio(1.0)
.finish();
Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(title)
.with_child(Container::new(subtitle).with_margin_top(16.).finish())
.finish()
}
fn render_sections(
&self,
appearance: &Appearance,
settings: &AgentDevelopmentSettings,
workspace_enforces_autonomy: bool,
app: &AppContext,
) -> Box<dyn Element> {
let model_section = self.render_model_section(appearance, settings, app);
let autonomy_section = if workspace_enforces_autonomy {
self.render_autonomy_workspace_enforced(appearance)
} else {
self.render_autonomy_section(appearance, settings)
};
let upper_col = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(model_section)
.with_child(
Container::new(autonomy_section)
.with_margin_top(24.)
.finish(),
);
// Apply a semi-transparent overlay to visually disable the upper sections
// when the "Disable Oz" checkbox is checked.
let upper_sections: Box<dyn Element> = if settings.disable_oz {
let bg = appearance.theme().background().into_solid();
let overlay_color = ColorU::new(bg.r, bg.g, bg.b, 128);
Container::new(upper_col.finish())
.with_foreground_overlay(overlay_color)
.finish()
} else {
upper_col.finish()
};
let col = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(upper_sections);
Container::new(col.finish()).with_margin_top(40.).finish()
}
fn render_section_header(
&self,
title: &'static str,
appearance: &Appearance,
) -> Box<dyn Element> {
appearance
.ui_builder()
.paragraph(title)
.with_style(UiComponentStyles {
font_size: Some(14.),
font_weight: Some(Weight::Medium),
..Default::default()
})
.build()
.finish()
}
fn render_model_section(
&self,
appearance: &Appearance,
settings: &AgentDevelopmentSettings,
app: &AppContext,
) -> Box<dyn Element> {
let header = self.render_section_header("Default model", appearance);
let expanded = self.is_model_list_expanded;
let chip = self.render_collapsed_model_chip(appearance, settings, app, expanded);
// Wrap the chip in a `Stack` so the floating dropdown overlay can live
// inline here (as a child of the left column) and inherit the chip's
// full column width via `ParentOffsetBounds::ParentBySize`. When the
// picker is collapsed the Stack has just the chip as its single child
// and lays out exactly like a bare chip would.
let mut chip_stack = Stack::new().with_child(chip);
if expanded {
let positioning = OffsetPositioning::from_axes(
PositioningAxis::relative_to_parent(
ParentOffsetBounds::ParentBySize,
OffsetType::Pixel(0.),
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
),
PositioningAxis::relative_to_parent(
ParentOffsetBounds::Unbounded,
OffsetType::Pixel(4.),
AnchorPair::new(YAxisAnchor::Bottom, YAxisAnchor::Top),
),
);
chip_stack.add_positioned_overlay_child(
self.render_model_list_overlay(appearance, app),
positioning,
);
}
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(header)
.with_child(
Container::new(chip_stack.finish())
.with_margin_top(12.)
.finish(),
)
.finish()
}
/// Renders the single-row collapsed picker button: provider icon, selected title,
/// and trailing chevron. `expanded` controls whether the chip draws its blue
/// "focused" border (when the full-list view is active) or the neutral border.
fn render_collapsed_model_chip(
&self,
appearance: &Appearance,
settings: &AgentDevelopmentSettings,
app: &AppContext,
expanded: bool,
) -> Box<dyn Element> {
const CHIP_HEIGHT: f32 = 48.;
const CHIP_RADIUS: f32 = 8.;
let theme = appearance.theme();
let background_for_text = theme.background().into_solid();
let ui_font_family = appearance.ui_font_family();
let models = self.onboarding_state.as_ref(app).models();
let selected = models
.iter()
.find(|m| m.id == settings.selected_model_id)
.or_else(|| models.first());
let (title_text, icon) = match selected {
Some(model) => (model.title.clone(), Some(model.icon)),
None => ("".to_string(), None),
};
let is_disabled = settings.disable_oz;
let title_color: ColorU = if is_disabled {
internal_colors::text_disabled(theme, background_for_text)
} else {
internal_colors::text_main(theme, background_for_text)
};
let border_color = if expanded && !is_disabled {
theme.accent()
} else {
Fill::Solid(internal_colors::neutral_4(theme))
};
let mouse_state = self.chip_mouse_state.clone();
let hoverable = Hoverable::new(mouse_state, move |_| {
let title_el = Text::new(title_text.clone(), ui_font_family, 14.0)
.with_color(title_color)
.with_style(Properties {
weight: Weight::Normal,
..Default::default()
})
.with_line_height_ratio(1.0)
.finish();
let title_row: Box<dyn Element> = if let Some(icon) = icon {
const ICON_SIZE: f32 = 14.;
let icon_el =
ConstrainedBox::new(Box::new(icon.to_galaxyui_icon(title_color.into())))
.with_width(ICON_SIZE)
.with_height(ICON_SIZE)
.finish();
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(icon_el)
.with_child(Container::new(title_el).with_margin_left(8.).finish())
.finish()
} else {
title_el
};
// Trailing chevron icon.
let chevron = ConstrainedBox::new(Box::new(WarpUiIcon::new(
"bundled/svg/chevron-down.svg",
title_color,
)))
.with_width(14.)
.with_height(14.)
.finish();
let row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(title_row)
.with_child(chevron)
.finish();
ConstrainedBox::new(
Container::new(row)
.with_horizontal_padding(16.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(CHIP_RADIUS)))
.with_border(Border::all(1.).with_border_fill(border_color))
.finish(),
)
.with_min_height(CHIP_HEIGHT)
.finish()
});
if is_disabled {
hoverable.finish()
} else {
hoverable
.with_cursor(Cursor::PointingHand)
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(AgentSlideAction::ToggleModelListExpanded);
})
.finish()
}
}
/// Renders the vertical list of model rows shown inside the floating dropdown
/// overlay. Each row shows a provider icon + title on the left, with a
/// "Recommended" pill on the right for the default model.
fn render_model_list_rows(
&self,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
const ROW_HEIGHT: f32 = 48.;
const ROW_GAP: f32 = 2.;
let state = self.onboarding_state.as_ref(app);
let highlighted_id = self.highlighted_model_id.clone();
let selected_id = state.agent_settings().selected_model_id.clone();
let models = state.models();
let mut col = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
for (index, model) in models.iter().enumerate() {
let mouse_state = self
.model_mouse_states
.get(index)
.cloned()
.unwrap_or_default();
let is_highlighted = highlighted_id.as_ref() == Some(&model.id)
|| (highlighted_id.is_none() && model.id == selected_id);
let row =
self.render_model_row(appearance, model, is_highlighted, mouse_state, ROW_HEIGHT);
// Wrap each row in `SavePosition` so the scrollable can scroll
// the keyboard-highlighted row into view (see
// `advance_highlighted_model`). Mirrors the pattern in
// `VerticalTabsPanelState::scroll_to_tab`.
let row = SavePosition::new(row, &model_row_position_id(index)).finish();
let margin_top = if index == 0 { 0. } else { ROW_GAP };
col = col.with_child(Container::new(row).with_margin_top(margin_top).finish());
}
col.finish()
}
fn render_model_list_overlay(
&self,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
const OVERLAY_RADIUS: f32 = 8.;
const OVERLAY_PADDING: f32 = 4.;
const OVERLAY_MAX_HEIGHT: f32 = 400.;
let theme = appearance.theme();
let list = self.render_model_list_rows(appearance, app);
// Wrap the list in a vertical `ClippedScrollable` so rows scroll when
// they exceed `OVERLAY_MAX_HEIGHT`.
let scrollable = ClippedScrollable::vertical(
self.dropdown_scroll_state.clone(),
list,
ScrollbarWidth::Auto,
theme.disabled_text_color(theme.surface_1()).into(),
theme.main_text_color(theme.surface_1()).into(),
theme.surface_1().into(),
)
.with_overlayed_scrollbar()
.finish();
let card = ConstrainedBox::new(
Container::new(scrollable)
.with_background(theme.surface_1())
.with_border(Border::all(1.).with_border_color(internal_colors::neutral_4(theme)))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(OVERLAY_RADIUS)))
.with_uniform_padding(OVERLAY_PADDING)
.with_drop_shadow(DropShadow::default())
.finish(),
)
.with_max_height(OVERLAY_MAX_HEIGHT)
.finish();
Dismiss::new(card)
.prevent_interaction_with_other_elements()
.on_dismiss(|ctx, _| {
ctx.dispatch_typed_action(AgentSlideAction::ToggleModelListExpanded);
})
.finish()
}
fn render_model_row(
&self,
appearance: &Appearance,
model: &OnboardingModelInfo,
is_highlighted: bool,
mouse_state: MouseStateHandle,
height: f32,
) -> Box<dyn Element> {
const ROW_RADIUS: f32 = 6.;
let theme = appearance.theme();
let background_for_text = theme.background().into_solid();
let ui_font_family = appearance.ui_font_family();
let title_color: ColorU = internal_colors::text_main(theme, background_for_text);
let row_id = model.id.clone();
let title = model.title.clone();
let icon = model.icon;
let is_default = model.is_default;
let hoverable_body = Hoverable::new(mouse_state, move |_| {
let title_el = Text::new(title.clone(), ui_font_family, 14.0)
.with_color(title_color)
.with_style(Properties {
weight: Weight::Normal,
..Default::default()
})
.with_line_height_ratio(1.0)
.finish();
const ICON_SIZE: f32 = 14.;
let icon_el = ConstrainedBox::new(Box::new(icon.to_galaxyui_icon(title_color.into())))
.with_width(ICON_SIZE)
.with_height(ICON_SIZE)
.finish();
let title_row: Box<dyn Element> = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(icon_el)
.with_child(Container::new(title_el).with_margin_left(8.).finish())
.finish();
// "Recommended" pill on the server-designated default model.
let make_pill = |label: &'static str| -> Box<dyn Element> {
let badge = Text::new(label.to_string(), ui_font_family, 11.0)
.with_color(internal_colors::text_sub(theme, background_for_text))
.with_style(Properties {
weight: Weight::Normal,
..Default::default()
})
.with_line_height_ratio(1.0)
.finish();
Container::new(badge)
.with_padding_left(8.)
.with_padding_right(8.)
.with_padding_top(4.)
.with_padding_bottom(4.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_background(Fill::Solid(internal_colors::neutral_3(theme)))
.finish()
};
let trailing: Box<dyn Element> = if is_default {
make_pill("Recommended")
} else {
Empty::new().finish()
};
let row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(title_row)
.with_child(trailing)
.finish();
let background = if is_highlighted {
Some(Fill::Solid(internal_colors::neutral_2(theme)))
} else {
None
};
let mut container = Container::new(row)
.with_horizontal_padding(12.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(ROW_RADIUS)));
if let Some(bg) = background {
container = container.with_background(bg);
}
ConstrainedBox::new(container.finish())
.with_min_height(height)
.finish()
});
let click_id = row_id.clone();
let hover_id = row_id;
hoverable_body
.with_cursor(Cursor::PointingHand)
.on_hover(move |is_hovered, ctx, _, _| {
if is_hovered {
ctx.dispatch_typed_action(AgentSlideAction::HighlightModel(hover_id.clone()));
}
})
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(AgentSlideAction::SelectModel(click_id.clone()));
})
.finish()
}
fn render_autonomy_workspace_enforced(&self, appearance: &Appearance) -> Box<dyn Element> {
let header = self.render_section_header("Autonomy", appearance);
let theme = appearance.theme();
let background_for_text = theme.background().into_solid();
let ui_font_family = appearance.ui_font_family();
let title_color = internal_colors::text_main(theme, background_for_text);
let subtitle_color = internal_colors::text_sub(theme, background_for_text);
let title_el = Text::new("Set by Team Workspace", ui_font_family, 14.0)
.with_color(title_color)
.with_style(Properties {
weight: Weight::Normal,
..Default::default()
})
.with_line_height_ratio(1.0)
.finish();
let subtitle_el = Text::new(
"Autonomy settings are configured as part of your team workspace.",
ui_font_family,
12.0,
)
.with_color(subtitle_color)
.with_style(Properties {
weight: Weight::Normal,
..Default::default()
})
.with_line_height_ratio(1.0)
.finish();
let content = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(title_el)
.with_child(Container::new(subtitle_el).with_margin_top(8.).finish())
.finish();
let message_box = Container::new(content)
.with_uniform_padding(12.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_border(Border::all(1.).with_border_color(internal_colors::neutral_4(theme)))
.finish();
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(header)
.with_child(Container::new(message_box).with_margin_top(12.).finish())
.finish()
}
fn render_autonomy_section(
&self,
appearance: &Appearance,
settings: &AgentDevelopmentSettings,
) -> Box<dyn Element> {
let header = self.render_section_header("Autonomy", appearance);
// The rows now take the full column width (vs. the previous three-across layout),
// so they no longer need the extra height that came from cramped subtitle wrapping.
const OPTION_HEIGHT: f32 = 72.;
const OPTION_GAP: f32 = 8.;
let theme = appearance.theme();
let background_for_text = theme.background().into_solid();
let text_main = internal_colors::text_main(theme, background_for_text);
let text_sub = internal_colors::text_sub(theme, background_for_text);
let autonomy_options: [(AgentAutonomy, &str, &str, MouseStateHandle); 3] = [
(
AgentAutonomy::Full,
"Full",
"Warp Agent runs commands, writes code, and reads files without asking.",
self.autonomy_full_mouse_state.clone(),
),
(
AgentAutonomy::Partial,
"Partial",
"Warp Agent can plan, read files, and execute low-risk commands. Asks before making any changes or executing sensitive commands.",
self.autonomy_partial_mouse_state.clone(),
),
(
AgentAutonomy::None,
"None",
"Warp Agent takes no actions without your approval.",
self.autonomy_none_mouse_state.clone(),
),
];
let mut col = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
for (idx, (autonomy, title, subtitle, mouse_state)) in
autonomy_options.into_iter().enumerate()
{
let is_selected = !settings.disable_oz && settings.autonomy == Some(autonomy);
let (title_color, subtitle_color) = if is_selected {
(text_main.into(), text_main.into())
} else {
(text_sub.into(), text_sub.into())
};
let button = render_two_line_button(
appearance,
TwoLineButtonSpec {
is_selected,
title: title.to_string(),
subtitle: subtitle.to_string(),
height: OPTION_HEIGHT,
mouse_state,
click_action: AgentSlideAction::SelectAutonomy(autonomy),
subtitle_font_size: 12.0,
title_color,
subtitle_color,
icon: None,
disabled_badge: None,
is_disabled: settings.disable_oz,
},
);
let margin_top = if idx == 0 { 0. } else { OPTION_GAP };
col = col.with_child(Container::new(button).with_margin_top(margin_top).finish());
}
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(header)
.with_child(Container::new(col.finish()).with_margin_top(12.).finish())
.finish()
}
fn render_bottom_nav(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let back_button = self.back_button.render(
appearance,
button::Params {
content: button::Content::Label("Back".into()),
theme: &button::themes::Naked,
options: button::Options {
on_click: Some(Box::new(|ctx, _app, _pos| {
ctx.dispatch_typed_action(AgentSlideAction::BackClicked);
})),
..button::Options::default(appearance)
},
},
);
let enter = Keystroke::parse("enter").unwrap_or_default();
let next_button = self.next_button.render(
appearance,
button::Params {
content: button::Content::Label("Next".into()),
theme: &button::themes::Primary,
options: button::Options {
keystroke: Some(enter),
on_click: Some(Box::new(|ctx, _app, _pos| {
ctx.dispatch_typed_action(AgentSlideAction::NextClicked);
})),
..button::Options::default(appearance)
},
},
);
let (step_index, step_count) = self.onboarding_state.as_ref(app).progress();
bottom_nav::onboarding_bottom_nav(
appearance,
step_index,
step_count,
Some(back_button),
Some(next_button),
)
}
fn render_visual(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let theme = appearance.theme();
if FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
let use_vertical = self
.onboarding_state
.as_ref(app)
.ui_customization()
.use_vertical_tabs;
let path = if use_vertical {
"async/png/onboarding/agent_intention/customize_vertical_tabs.png"
} else {
"async/png/onboarding/agent_intention/customize_horizontal_tabs.png"
};
layout::onboarding_right_panel_with_bg(path, layout::FOREGROUND_LAYOUT_WIDE)
} else {
let panel_background = internal_colors::neutral_2(theme);
let neutral = internal_colors::neutral_4(theme);
let blue = theme.ansi_fg_blue();
let green = theme.ansi_fg_green();
let yellow = theme.ansi_fg_yellow();
Container::new(agent_visual(panel_background, neutral, blue, green, yellow))
.with_background_color(internal_colors::neutral_1(theme))
.finish()
}
}
}
impl Entity for AgentSlide {
type Event = ();
}
impl View for AgentSlide {
fn ui_name() -> &'static str {
"AgentSlide"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let settings = self.agent_settings(app);
let workspace_enforces_autonomy = self.workspace_enforces_autonomy(app);
// The floating dropdown overlay is built inside `render_model_section`
// so it inherits the column width naturally. Here we only need the
// base two-column layout.
layout::static_left(
|| self.render_content(appearance, settings, workspace_enforces_autonomy, app),
|| self.render_visual(appearance, app),
)
}
}
impl AgentSlide {
fn select_model(&mut self, model_id: LLMId, ctx: &mut ViewContext<Self>) {
self.onboarding_state.update(ctx, |state, ctx| {
state.on_user_selected_model(model_id, ctx);
});
ctx.notify();
}
fn set_model_list_expanded(&mut self, expanded: bool, ctx: &mut ViewContext<Self>) {
if self.is_model_list_expanded == expanded {
return;
}
self.is_model_list_expanded = expanded;
if expanded {
// Seed the highlight from the current selection so keyboard nav
// starts on the selected row.
let state = self.onboarding_state.as_ref(ctx);
let selected_id = state.agent_settings().selected_model_id.clone();
if let Some(index) = state.models().iter().position(|m| m.id == selected_id) {
self.dropdown_scroll_state.scroll_to_position(ScrollTarget {
position_id: model_row_position_id(index),
mode: ScrollToPositionMode::FullyIntoView,
});
}
self.highlighted_model_id = Some(selected_id);
}
ctx.notify();
}
/// Advances the highlight cursor to the next/previous model, wrapping. The
/// origin of the walk is the currently-highlighted id (if any), else the
/// currently-selected id. Also scrolls the dropdown so the newly-highlighted
/// row stays visible — same `SavePosition` + `scroll_to_position` pattern
/// used by `VerticalTabsPanelState::scroll_to_tab`.
fn advance_highlighted_model(&mut self, forward: bool, ctx: &mut ViewContext<Self>) {
let (model_ids, selected_id) = {
let state = self.onboarding_state.as_ref(ctx);
let ids: Vec<LLMId> = state.models().iter().map(|m| m.id.clone()).collect();
(ids, state.agent_settings().selected_model_id.clone())
};
let count = model_ids.len();
if count == 0 {
return;
}
let start_index = self
.highlighted_model_id
.as_ref()
.and_then(|id| model_ids.iter().position(|m| m == id))
.or_else(|| model_ids.iter().position(|m| *m == selected_id))
.unwrap_or(0);
let next_index = if forward {
(start_index + 1) % count
} else {
(start_index + count - 1) % count
};
self.highlighted_model_id = Some(model_ids[next_index].clone());
// Scroll the dropdown so the new highlight is visible. `FullyIntoView`
// is a no-op when the row is already fully in view, otherwise it
// scrolls the minimum amount to show it.
self.dropdown_scroll_state.scroll_to_position(ScrollTarget {
position_id: model_row_position_id(next_index),
mode: ScrollToPositionMode::FullyIntoView,
});
ctx.notify();
}
fn select_autonomy(&mut self, autonomy: AgentAutonomy, ctx: &mut ViewContext<Self>) {
if self.workspace_enforces_autonomy(ctx) {
return;
}
self.onboarding_state.update(ctx, |state, ctx| {
state.set_agent_autonomy(autonomy, ctx);
});
ctx.notify();
}
fn next(&mut self, ctx: &mut ViewContext<Self>) {
self.onboarding_state.update(ctx, |state, ctx| {
state.next(ctx);
});
}
}
impl OnboardingSlide for AgentSlide {
fn on_up(&mut self, ctx: &mut ViewContext<Self>) {
if self.agent_settings(ctx).disable_oz {
return;
}
// While the picker is expanded, arrow keys move the highlight cursor
// instead of cycling autonomy.
if self.is_model_list_expanded {
self.advance_highlighted_model(/* forward */ false, ctx);
return;
}
if self.workspace_enforces_autonomy(ctx) {
return;
}
let Some(autonomy) = self.agent_settings(ctx).autonomy else {
return;
};
let up_autonomy = match autonomy {
AgentAutonomy::Full => AgentAutonomy::None,
AgentAutonomy::Partial => AgentAutonomy::Full,
AgentAutonomy::None => AgentAutonomy::Partial,
};
self.select_autonomy(up_autonomy, ctx);
}
fn on_down(&mut self, ctx: &mut ViewContext<Self>) {
if self.agent_settings(ctx).disable_oz {
return;
}
if self.is_model_list_expanded {
self.advance_highlighted_model(/* forward */ true, ctx);
return;
}
if self.workspace_enforces_autonomy(ctx) {
return;
}
let Some(autonomy) = self.agent_settings(ctx).autonomy else {
return;
};
let down_autonomy = match autonomy {
AgentAutonomy::Full => AgentAutonomy::Partial,
AgentAutonomy::Partial => AgentAutonomy::None,
AgentAutonomy::None => AgentAutonomy::Full,
};
self.select_autonomy(down_autonomy, ctx);
}
fn on_enter(&mut self, ctx: &mut ViewContext<Self>) {
// If the picker is expanded: Enter selects the highlighted row (if any)
// and collapses the list. Does NOT advance to the next slide.
if self.is_model_list_expanded {
if let Some(id) = self.highlighted_model_id.clone() {
self.select_model(id, ctx);
}
self.set_model_list_expanded(false, ctx);
return;
}
self.next(ctx);
}
fn on_escape(&mut self, ctx: &mut ViewContext<Self>) {
// Escape closes the expanded picker without mutating selection.
if self.is_model_list_expanded {
self.set_model_list_expanded(false, ctx);
}
}
}
impl TypedActionView for AgentSlide {
type Action = AgentSlideAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
AgentSlideAction::SelectModel(model_id) => {
if !self.agent_settings(ctx).disable_oz {
self.select_model(model_id.clone(), ctx);
// If the picker is open, clicking a row collapses it after selecting.
if self.is_model_list_expanded {
self.set_model_list_expanded(false, ctx);
}
}
}
AgentSlideAction::ToggleModelListExpanded => {
if self.agent_settings(ctx).disable_oz {
return;
}
self.set_model_list_expanded(!self.is_model_list_expanded, ctx);
}
AgentSlideAction::HighlightModel(model_id) => {
if self.highlighted_model_id.as_ref() != Some(model_id) {
self.highlighted_model_id = Some(model_id.clone());
ctx.notify();
}
}
AgentSlideAction::SelectAutonomy(autonomy) => {
if !self.agent_settings(ctx).disable_oz {
self.select_autonomy(*autonomy, ctx);
}
}
AgentSlideAction::ToggleDisableOz => {
let current = self
.onboarding_state
.as_ref(ctx)
.agent_settings()
.disable_oz;
self.onboarding_state.update(ctx, |state, ctx| {
state.set_disable_oz(!current, ctx);
});
ctx.notify();
}
AgentSlideAction::BackClicked => {
self.onboarding_state.update(ctx, |state, ctx| {
state.back(ctx);
});
}
AgentSlideAction::NextClicked => {
self.next(ctx);
}
}
}
}