first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+315 -99
View File
@@ -1,42 +1,50 @@
use std::time::Duration;
use ai::LLMId;
use instant::Instant;
use galaxy_core::features::FeatureFlag;
use galaxy_core::send_telemetry_from_ctx;
use galaxyui_core::assets::asset_cache::AssetSource;
use galaxyui_core::image_cache::ImageType;
use galaxyui_core::windowing::state::{ApplicationStage, StateEvent};
use galaxyui_core::windowing::WindowManager;
use crate::components::feature_optout_dialog::{render_feature_optout_dialog, FeatureOptOutDialog};
use crate::model::{
OnboardingAuthState, OnboardingStateEvent, OnboardingStateModel, OnboardingStep,
SelectedSettings,
};
use crate::slides::{
AgentSlide, AgentSlideEvent, CustomizeUISlide, FreeUserNoAiSlide, IntentionSlide, IntroSlide,
IntroSlideEvent, OnboardingModelInfo, OnboardingSlide, ProjectSlide, ThemePickerSlide,
ThemePickerSlideEvent, ThirdPartySlide,
AgentSlide, AiAccessSlide, AiAccessSlideEvent, AiSetupSlide, CustomizeUISlide, IntentionSlide,
IntroSlide, IntroSlideEvent, OnboardingModelInfo, OnboardingSlide, ProjectSlide,
ThemePickerSlide, ThemePickerSlideEvent, ThirdPartySlide,
};
use crate::telemetry::OnboardingEvent;
use ai::LLMId;
use galaxy_core::features::FeatureFlag;
use galaxy_core::send_telemetry_from_ctx;
use galaxyui::assets::asset_cache::AssetSource;
use galaxyui::image_cache::ImageType;
use galaxyui::windowing::{
state::{ApplicationStage, StateEvent},
WindowManager,
};
use instant::Instant;
use std::time::Duration;
const APP_BECAME_ACTIVE_DEBOUNCE: Duration = Duration::from_secs(15);
use galaxy_core::ui::{appearance::Appearance, theme::GalaxyTheme};
use galaxyui::elements::Rect;
use galaxyui::{
elements::{
CacheOption, ChildAnchor, Container, Empty, Image, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Shrinkable, Stack,
},
keymap::Keystroke,
keymap::{macros::*, FixedBinding},
presenter::ChildView,
const PLAN_ACTIVATED_TOAST_DURATION: Duration = Duration::from_secs(5);
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use ui_components::{button, Component as _, Options as _};
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::{Fill, WarpTheme};
use galaxy_core::ui::Icon;
use galaxyui_core::elements::{
Align, CacheOption, ChildAnchor, ConstrainedBox, Container, CrossAxisAlignment, Dismiss, Empty,
Flex, Image, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning,
ParentAnchor, ParentElement, ParentOffsetBounds, Rect, Shrinkable, Stack,
};
use galaxyui_core::fonts::Weight;
use galaxyui_core::keymap::macros::*;
use galaxyui_core::keymap::{FixedBinding, Keystroke};
use galaxyui_core::presenter::ChildView;
use galaxyui_core::ui_components::components::{UiComponent as _, UiComponentStyles};
use galaxyui_core::{
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
ViewContext, ViewHandle,
};
use pathfinder_geometry::vector::vec2f;
use ui_components::{button, Component as _, Options as _};
#[derive(Clone, Debug)]
pub enum AgentOnboardingEvent {
@@ -68,14 +76,21 @@ pub struct AgentOnboardingView {
intro_slide: ViewHandle<IntroSlide>,
theme_picker_slide: ViewHandle<ThemePickerSlide>,
intention_slide: ViewHandle<IntentionSlide>,
ai_setup_slide: ViewHandle<AiSetupSlide>,
customize_slide: ViewHandle<CustomizeUISlide>,
free_user_no_ai_slide: ViewHandle<FreeUserNoAiSlide>,
agent_slide: ViewHandle<AgentSlide>,
ai_access_slide: ViewHandle<AiAccessSlide>,
third_party_slide: ViewHandle<ThirdPartySlide>,
project_slide: ViewHandle<ProjectSlide>,
skippable: bool,
close_button: button::Button,
no_ai_confirm_button: button::Button,
no_ai_cancel_button: button::Button,
no_ai_close_button: button::Button,
last_model_refresh: Option<Instant>,
show_plan_activated_toast: bool,
last_auth_state: OnboardingAuthState,
plan_activated_close_mouse_state: MouseStateHandle,
}
#[derive(Clone, Copy, Debug)]
@@ -88,6 +103,10 @@ pub enum AgentOnboardingAction {
EnterKey,
CmdOrCtrlEnterKey,
Escape,
NoAiConfirm,
NoAiCancel,
NoAiDismiss,
DismissPlanActivatedToast,
}
fn dispatch_onboarding_action_to_slide<V: OnboardingSlide>(
@@ -104,6 +123,11 @@ fn dispatch_onboarding_action_to_slide<V: OnboardingSlide>(
AgentOnboardingAction::EnterKey => slide.on_enter(ctx),
AgentOnboardingAction::CmdOrCtrlEnterKey => slide.on_cmd_or_ctrl_enter(ctx),
AgentOnboardingAction::Escape => slide.on_escape(ctx),
// Parent-level actions are handled by the parent view, never routed to a slide.
AgentOnboardingAction::NoAiConfirm
| AgentOnboardingAction::NoAiCancel
| AgentOnboardingAction::NoAiDismiss
| AgentOnboardingAction::DismissPlanActivatedToast => {}
}
}
@@ -117,8 +141,6 @@ impl AgentOnboardingView {
default_model_id: LLMId,
workspace_enforces_autonomy: bool,
agent_modality_enabled: bool,
free_user_no_ai_experiment: bool,
agent_price_cents: Option<i32>,
auth_state: OnboardingAuthState,
ctx: &mut ViewContext<Self>,
) -> Self {
@@ -128,8 +150,6 @@ impl AgentOnboardingView {
default_model_id,
workspace_enforces_autonomy,
agent_modality_enabled,
free_user_no_ai_experiment,
agent_price_cents,
auth_state,
)
});
@@ -147,7 +167,13 @@ impl AgentOnboardingView {
OnboardingStateEvent::UpgradeRequested => {
ctx.emit(AgentOnboardingEvent::UpgradeRequested);
}
_ => {}
OnboardingStateEvent::AuthStateChanged => {
me.handle_auth_state_changed(ctx);
}
OnboardingStateEvent::ModelsUpdated
| OnboardingStateEvent::SelectedSlideChanged
| OnboardingStateEvent::IntentionChanged
| OnboardingStateEvent::NoAiConfirmationChanged => {}
}
});
@@ -175,14 +201,15 @@ impl AgentOnboardingView {
ctx.add_typed_action_view(move |_| IntentionSlide::new(onboarding_state))
};
let ai_setup_slide = {
let onboarding_state = onboarding_state.clone();
ctx.add_typed_action_view(move |_| AiSetupSlide::new(onboarding_state))
};
let customize_slide = {
let onboarding_state = onboarding_state.clone();
ctx.add_typed_action_view(move |ctx| CustomizeUISlide::new(onboarding_state, ctx))
};
let free_user_no_ai_slide = {
let onboarding_state = onboarding_state.clone();
ctx.add_typed_action_view(move |_| FreeUserNoAiSlide::new(onboarding_state))
};
ctx.subscribe_to_view(&theme_picker_slide, |me, _view, event, ctx| {
me.handle_theme_picker_slide_event(event, ctx);
@@ -193,11 +220,16 @@ impl AgentOnboardingView {
ctx.add_typed_action_view(move |ctx| AgentSlide::new(onboarding_state, ctx))
};
ctx.subscribe_to_view(&agent_slide, |_me, _view, event, ctx| match event {
AgentSlideEvent::CopyUpgradeUrlRequested => {
let ai_access_slide = {
let onboarding_state = onboarding_state.clone();
ctx.add_typed_action_view(move |_| AiAccessSlide::new(onboarding_state))
};
ctx.subscribe_to_view(&ai_access_slide, |_me, _view, event, ctx| match event {
AiAccessSlideEvent::CopyUpgradeUrlRequested => {
ctx.emit(AgentOnboardingEvent::UpgradeCopyUrlRequested);
}
AgentSlideEvent::PasteAuthTokenFromClipboardRequested => {
AiAccessSlideEvent::PasteAuthTokenFromClipboardRequested => {
ctx.emit(AgentOnboardingEvent::UpgradePasteTokenFromClipboardRequested);
}
});
@@ -236,14 +268,21 @@ impl AgentOnboardingView {
intro_slide,
theme_picker_slide,
intention_slide,
ai_setup_slide,
customize_slide,
free_user_no_ai_slide,
agent_slide,
ai_access_slide,
third_party_slide,
project_slide,
skippable,
close_button: button::Button::default(),
no_ai_confirm_button: button::Button::default(),
no_ai_cancel_button: button::Button::default(),
no_ai_close_button: button::Button::default(),
last_model_refresh: None,
show_plan_activated_toast: false,
last_auth_state: auth_state,
plan_activated_close_mouse_state: MouseStateHandle::default(),
}
}
@@ -274,12 +313,6 @@ impl AgentOnboardingView {
ctx.notify();
}
pub fn free_user_no_ai_experiment(&self, ctx: &AppContext) -> bool {
self.onboarding_state
.as_ref(ctx)
.free_user_no_ai_experiment()
}
/// The current `use_vertical_tabs` value on the onboarding UI customization.
/// This reflects the intention's default (agent = vertical, terminal = horizontal)
/// and any change the user made on the customize slide, and is what the
@@ -291,32 +324,6 @@ impl AgentOnboardingView {
.use_vertical_tabs
}
pub fn set_agent_price_cents(&mut self, cents: Option<i32>, ctx: &mut ViewContext<Self>) {
self.onboarding_state.update(ctx, |state, ctx| {
state.set_agent_price_cents(cents, ctx);
});
ctx.notify();
}
pub fn set_free_user_no_ai_experiment(&mut self, value: bool, ctx: &mut ViewContext<Self>) {
self.onboarding_state.update(ctx, |state, ctx| {
state.set_free_user_no_ai_experiment(value, ctx);
});
ctx.notify();
}
/// When the user upgrades during the FreeUserNoAi experiment, advance directly
/// to the Agent setup step (skipping the intention slide — they've already chosen).
pub fn advance_to_agent_step(&mut self, ctx: &mut ViewContext<Self>) {
let step = self.onboarding_state.as_ref(ctx).step();
if matches!(step, OnboardingStep::Intention) {
self.onboarding_state.update(ctx, |model, ctx| {
model.set_intention_agent_driven_development(ctx);
model.next(ctx); // Intention → Agent
});
}
}
pub fn start_onboarding(&self, ctx: &mut ViewContext<Self>) {
// Focus the onboarding view so key bindings (Enter, arrow keys, etc.) are routed here
// instead of to other views (e.g. the editor).
@@ -339,7 +346,7 @@ impl AgentOnboardingView {
/// Eagerly loads all onboarding slide images into the asset cache
/// so they display instantly when the user navigates between slides.
fn preload_onboarding_images(ctx: &mut ViewContext<Self>) {
let asset_cache = galaxyui::assets::asset_cache::AssetCache::as_ref(ctx);
let asset_cache = galaxyui_core::assets::asset_cache::AssetCache::as_ref(ctx);
// Preload the shared background image used on all right panels.
asset_cache.load_asset::<ImageType>(AssetSource::Bundled {
path: crate::slides::layout::ONBOARDING_BG_PATH,
@@ -347,6 +354,12 @@ impl AgentOnboardingView {
for path in IntentionSlide::VISUAL_IMAGE_PATHS {
asset_cache.load_asset::<ImageType>(AssetSource::Bundled { path });
}
for path in AiSetupSlide::VISUAL_IMAGE_PATHS {
asset_cache.load_asset::<ImageType>(AssetSource::Bundled { path });
}
for path in AiAccessSlide::VISUAL_IMAGE_PATHS {
asset_cache.load_asset::<ImageType>(AssetSource::Bundled { path });
}
for path in CustomizeUISlide::VISUAL_IMAGE_PATHS {
asset_cache.load_asset::<ImageType>(AssetSource::Bundled { path });
}
@@ -360,11 +373,169 @@ impl AgentOnboardingView {
// which are already in CustomizeUISlide::VISUAL_IMAGE_PATHS.
}
fn render_no_ai_dialog(&self, appearance: &Appearance) -> Box<dyn Element> {
let escape = Keystroke::parse("escape").unwrap_or_default();
let close_button = self.no_ai_close_button.render(
appearance,
button::Params {
content: button::Content::Icon(Icon::X),
theme: &button::themes::Naked,
options: button::Options {
keystroke: Some(escape),
on_click: Some(Box::new(|ctx, _app, _pos| {
ctx.dispatch_typed_action(AgentOnboardingAction::NoAiDismiss);
})),
..button::Options::default(appearance)
},
},
);
let cancel_button = self.no_ai_cancel_button.render(
appearance,
button::Params {
content: button::Content::Label("Give me AI features".into()),
theme: &button::themes::Naked,
options: button::Options {
on_click: Some(Box::new(|ctx, _app, _pos| {
ctx.dispatch_typed_action(AgentOnboardingAction::NoAiCancel);
})),
..button::Options::default(appearance)
},
},
);
let enter = Keystroke::parse("enter").unwrap_or_default();
let confirm_button = self.no_ai_confirm_button.render(
appearance,
button::Params {
content: button::Content::Label("I don't want AI".into()),
theme: &button::themes::Primary,
options: button::Options {
keystroke: Some(enter),
on_click: Some(Box::new(|ctx, _app, _pos| {
ctx.dispatch_typed_action(AgentOnboardingAction::NoAiConfirm);
})),
..button::Options::default(appearance)
},
},
);
render_feature_optout_dialog(
appearance,
FeatureOptOutDialog {
title: "Are you sure you don't want AI?",
body: "Without AI, you'll still get Warp's terminal experience, but you'll miss \
our agentic features like automatic fixes for terminal errors.",
features: &[],
close_button,
cancel_button,
confirm_button,
},
)
}
fn handle_onboarding_completed(&mut self, ctx: &mut ViewContext<Self>) {
let settings = self.onboarding_state.as_ref(ctx).settings();
ctx.emit(AgentOnboardingEvent::OnboardingCompleted(settings));
}
/// Reacts to a billing/auth transition. When the user becomes a paying user
/// we show a success toast; if they're still on the AI-access slide we also
/// advance them, since selecting a plan was the remaining action there.
fn handle_auth_state_changed(&mut self, ctx: &mut ViewContext<Self>) {
let new_state = self.onboarding_state.as_ref(ctx).auth_state();
let became_paying = new_state == OnboardingAuthState::PayingUser
&& self.last_auth_state != OnboardingAuthState::PayingUser;
self.last_auth_state = new_state;
if !became_paying {
return;
}
let on_ai_access = self.onboarding_state.as_ref(ctx).step() == OnboardingStep::AiAccess;
if on_ai_access {
self.onboarding_state
.update(ctx, |model, ctx| model.next(ctx));
}
self.show_plan_activated_toast = true;
let _ = ctx.spawn(
galaxyui_core::r#async::Timer::after(PLAN_ACTIVATED_TOAST_DURATION),
|me: &mut Self, _, ctx| {
if me.show_plan_activated_toast {
me.show_plan_activated_toast = false;
ctx.notify();
}
},
);
}
/// Green success pill shown after billing succeeds. Hosted at the view level
/// (not the slide) so it survives the auto-advance off the AI-access slide.
fn render_plan_activated_toast(&self, appearance: &Appearance) -> Box<dyn Element> {
const TOAST_MIN_HEIGHT: f32 = 40.;
const ICON_SIZE: f32 = 14.;
const CLOSE_SIZE: f32 = 16.;
const FONT_SIZE: f32 = 12.;
let theme = appearance.theme();
let toast_bg: Fill = theme.ansi_fg_green().into();
let text_color: ColorU = theme.font_color(toast_bg.into_solid()).into();
let ui_builder = appearance.ui_builder();
let check_icon = ConstrainedBox::new(Box::new(
Icon::CheckSkinny.to_warpui_icon(Fill::Solid(text_color)),
))
.with_width(ICON_SIZE)
.with_height(ICON_SIZE)
.finish();
let text = ui_builder
.span("Plan successfully activated!")
.with_style(UiComponentStyles {
font_color: Some(text_color),
font_size: Some(FONT_SIZE),
font_weight: Some(Weight::Medium),
..Default::default()
})
.build()
.finish();
let close_button = ui_builder
.close_button(CLOSE_SIZE, self.plan_activated_close_mouse_state.clone())
.with_style(UiComponentStyles {
font_color: Some(text_color),
..Default::default()
})
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(AgentOnboardingAction::DismissPlanActivatedToast);
})
.finish();
let left = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(check_icon)
.with_child(Container::new(text).with_margin_left(8.).finish())
.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(left)
.with_child(close_button)
.finish();
ConstrainedBox::new(
Container::new(row)
.with_background(toast_bg)
.with_horizontal_padding(16.)
.finish(),
)
.with_min_height(TOAST_MIN_HEIGHT)
.finish()
}
fn handle_theme_picker_slide_event(
&mut self,
event: &ThemePickerSlideEvent,
@@ -432,19 +603,11 @@ impl View for AgentOnboardingView {
let slide = match selected_slide {
OnboardingStep::Intro => ChildView::new(&self.intro_slide).finish(),
OnboardingStep::ThemePicker => ChildView::new(&self.theme_picker_slide).finish(),
OnboardingStep::Intention => {
if self
.onboarding_state
.as_ref(app)
.free_user_no_ai_experiment()
{
ChildView::new(&self.free_user_no_ai_slide).finish()
} else {
ChildView::new(&self.intention_slide).finish()
}
}
OnboardingStep::Intention => ChildView::new(&self.intention_slide).finish(),
OnboardingStep::AiSetup => ChildView::new(&self.ai_setup_slide).finish(),
OnboardingStep::Customize => ChildView::new(&self.customize_slide).finish(),
OnboardingStep::Agent => ChildView::new(&self.agent_slide).finish(),
OnboardingStep::AiAccess => ChildView::new(&self.ai_access_slide).finish(),
OnboardingStep::ThirdParty => ChildView::new(&self.third_party_slide).finish(),
OnboardingStep::Project => ChildView::new(&self.project_slide).finish(),
};
@@ -481,6 +644,35 @@ impl View for AgentOnboardingView {
);
}
if self
.onboarding_state
.as_ref(app)
.no_ai_confirmation()
.is_some()
{
let dialog = self.render_no_ai_dialog(appearance);
stack.add_child(
Rect::new()
.with_background(Fill::Solid(ColorU::black()).with_opacity(60))
.finish(),
);
stack.add_child(
Dismiss::new(Align::new(dialog).finish())
.on_dismiss(|ctx, _app| {
ctx.dispatch_typed_action(AgentOnboardingAction::NoAiDismiss);
})
.finish(),
);
}
if self.show_plan_activated_toast {
stack.add_child(
Align::new(self.render_plan_activated_toast(appearance))
.bottom_center()
.finish(),
);
}
stack.finish()
}
}
@@ -489,11 +681,41 @@ impl TypedActionView for AgentOnboardingView {
type Action = AgentOnboardingAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
if self
.onboarding_state
.as_ref(ctx)
.no_ai_confirmation()
.is_some()
{
match action {
AgentOnboardingAction::NoAiConfirm | AgentOnboardingAction::EnterKey => {
self.onboarding_state
.update(ctx, |model, ctx| model.confirm_no_ai(ctx));
}
AgentOnboardingAction::NoAiCancel => {
self.onboarding_state
.update(ctx, |model, ctx| model.cancel_no_ai(ctx));
}
AgentOnboardingAction::NoAiDismiss | AgentOnboardingAction::Escape => {
self.onboarding_state
.update(ctx, |model, ctx| model.dismiss_no_ai(ctx));
}
_ => {}
}
return;
}
if matches!(action, AgentOnboardingAction::Escape) && self.skippable {
ctx.emit(AgentOnboardingEvent::OnboardingSkipped);
return;
}
if matches!(action, AgentOnboardingAction::DismissPlanActivatedToast) {
self.show_plan_activated_toast = false;
ctx.notify();
return;
}
let selected_slide = self.onboarding_state.as_ref(ctx).step();
match selected_slide {
@@ -503,27 +725,21 @@ impl TypedActionView for AgentOnboardingView {
OnboardingStep::ThemePicker => self.theme_picker_slide.update(ctx, |slide, ctx| {
dispatch_onboarding_action_to_slide(slide, *action, ctx)
}),
OnboardingStep::Intention => {
if self
.onboarding_state
.as_ref(ctx)
.free_user_no_ai_experiment()
{
self.free_user_no_ai_slide.update(ctx, |slide, ctx| {
dispatch_onboarding_action_to_slide(slide, *action, ctx)
})
} else {
self.intention_slide.update(ctx, |slide, ctx| {
dispatch_onboarding_action_to_slide(slide, *action, ctx)
})
}
}
OnboardingStep::Intention => self.intention_slide.update(ctx, |slide, ctx| {
dispatch_onboarding_action_to_slide(slide, *action, ctx)
}),
OnboardingStep::AiSetup => self.ai_setup_slide.update(ctx, |slide, ctx| {
dispatch_onboarding_action_to_slide(slide, *action, ctx)
}),
OnboardingStep::Customize => self.customize_slide.update(ctx, |slide, ctx| {
dispatch_onboarding_action_to_slide(slide, *action, ctx)
}),
OnboardingStep::Agent => self.agent_slide.update(ctx, |slide, ctx| {
dispatch_onboarding_action_to_slide(slide, *action, ctx)
}),
OnboardingStep::AiAccess => self.ai_access_slide.update(ctx, |slide, ctx| {
dispatch_onboarding_action_to_slide(slide, *action, ctx)
}),
OnboardingStep::ThirdParty => self.third_party_slide.update(ctx, |slide, ctx| {
dispatch_onboarding_action_to_slide(slide, *action, ctx)
}),
+29 -25
View File
@@ -1,29 +1,34 @@
#![allow(dead_code)]
use std::borrow::Cow;
use ai::LLMId;
use anyhow::Result;
use galaxy_core::ui::icons::Icon;
use galaxy_core::ui::theme::{AnsiColor, AnsiColors, Details, Fill, Image, TerminalColors};
use galaxy_core::ui::{appearance::Appearance, theme::GalaxyTheme};
use galaxyui::assets::asset_cache::AssetSource;
use galaxyui::platform;
use galaxyui::{
elements::{
Container, CrossAxisAlignment, Flex, MainAxisAlignment, MainAxisSize, ParentElement,
},
fonts::{Cache, FamilyId, Weight},
presenter::ChildView,
ui_components::components::{UiComponent as _, UiComponentStyles},
AddWindowOptions, AppContext, AssetProvider, Element, Entity, SingletonEntity as _,
TypedActionView, View, ViewContext, ViewHandle,
};
use onboarding::slides::OnboardingModelInfo;
use onboarding::{
AgentOnboardingEvent, AgentOnboardingView, MockTelemetryContextProvider, SelectedSettings,
};
use pathfinder_color::ColorU;
use rust_embed::RustEmbed;
use std::borrow::Cow;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::icons::Icon;
use galaxy_core::ui::theme::{
AnsiColor, AnsiColors, Details, Fill, Image, TerminalColors, WarpTheme,
};
use galaxyui_core::assets::asset_cache::AssetSource;
use galaxyui_core::elements::{
Container, CrossAxisAlignment, Flex, MainAxisAlignment, MainAxisSize, ParentElement,
};
use galaxyui_core::fonts::{Cache, FamilyId, Weight};
use galaxyui_core::presenter::ChildView;
use galaxyui_core::ui_components::components::{UiComponent as _, UiComponentStyles};
use galaxyui_core::{
platform, AddWindowOptions, AppContext, AssetProvider, Element, Entity, SingletonEntity as _,
TypedActionView, View, ViewContext, ViewHandle,
};
use onboarding::{
AgentOnboardingEvent, AgentOnboardingView, MockTelemetryContextProvider, SelectedSettings,
};
#[derive(Clone, Copy, RustEmbed)]
#[folder = "../../app/assets"]
@@ -44,10 +49,14 @@ fn main() -> Result<()> {
galaxy_logging::init(galaxy_logging::LogConfig {
is_cli: false,
log_destination: None,
..Default::default()
})?;
let app_builder =
platform::AppBuilder::new(platform::AppCallbacks::default(), Box::new(ASSETS), None);
let app_builder = warpui::platform::AppBuilder::new(
platform::AppCallbacks::default(),
Box::new(ASSETS),
None,
);
let _ = app_builder.run(move |ctx| {
// Register Appearance singleton so views can access Appearance::handle(ctx).
ctx.add_singleton_model(|ctx| build_appearance(phenomenon(), ctx));
@@ -84,26 +93,23 @@ impl OnboardingMainView {
id: LLMId::from("auto"),
title: "Auto".to_string(),
icon: Icon::Oz,
requires_upgrade: false,
is_default: true,
},
OnboardingModelInfo {
id: LLMId::from("claude-sonnet"),
title: "Claude Sonnet".to_string(),
icon: Icon::ClaudeLogo,
requires_upgrade: false,
is_default: false,
},
OnboardingModelInfo {
id: LLMId::from("gpt-4o"),
title: "GPT-4o".to_string(),
icon: Icon::OpenAILogo,
requires_upgrade: true,
is_default: false,
},
];
let onboarding_view = ctx.add_typed_action_view(move |ctx| {
// agent_modality_enabled and no_ai_experiment are false for demo purposes
// agent_modality_enabled is false for demo purposes
AgentOnboardingView::new(
themes.clone(),
true,
@@ -111,8 +117,6 @@ impl OnboardingMainView {
default_model_id.clone(),
false,
false,
false,
None,
onboarding::OnboardingAuthState::LoggedOut,
ctx,
)
@@ -266,7 +270,7 @@ impl View for OnboardingMainView {
}
}
fn on_focus(&mut self, focus_ctx: &galaxyui::FocusContext, ctx: &mut ViewContext<Self>) {
fn on_focus(&mut self, focus_ctx: &galaxyui_core::FocusContext, ctx: &mut ViewContext<Self>) {
if let OnboardingMainState::Onboarding(view) = &self.state {
if focus_ctx.is_self_focused() {
ctx.focus(view);
+1 -1
View File
@@ -4,6 +4,6 @@ mod view;
pub use model::{FinalState, OnboardingQuery};
pub use view::{OnboardingCalloutView, OnboardingCalloutViewEvent, OnboardingKeybindings};
pub fn init(app: &mut galaxyui::AppContext) {
pub fn init(app: &mut galaxyui_core::AppContext) {
view::init(app);
}
+28 -53
View File
@@ -1,7 +1,8 @@
use galaxy_core::send_telemetry_from_ctx;
use galaxyui_core::{Entity, ModelContext};
use crate::telemetry::OnboardingEvent;
use crate::OnboardingIntention;
use galaxy_core::send_telemetry_from_ctx;
use galaxyui::{Entity, ModelContext};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FinalState {
@@ -64,14 +65,10 @@ pub(super) enum UniversalInputCalloutState {
pub(super) enum AgentModalityCalloutState {
#[default]
Off,
/// Step 1: "Meet your terminal input" / "Meet your updated terminal input"
MeetTerminalInput,
/// Step 2: "Natural language support" with checkbox
NaturalLanguageSupport,
/// Step 3: "Introducing Warp's new agent experience" (Agent intention only)
IntroducingAgentExperience,
/// Step 4: "Updated agent input" (Agent intention only)
UpdatedAgentInput,
/// Step 1: terminal input with natural language support.
TerminalMode,
/// Step 2: "Agent Mode" (Agent intention only).
AgentMode,
/// Terminal state
Complete(FinalState),
}
@@ -187,13 +184,9 @@ impl OnboardingCalloutModel {
) {
let (next_state, emit_enter_agent_modality) = match state {
AgentModalityCalloutState::Off => {
(Some(AgentModalityCalloutState::MeetTerminalInput), false)
(Some(AgentModalityCalloutState::TerminalMode), false)
}
AgentModalityCalloutState::MeetTerminalInput => (
Some(AgentModalityCalloutState::NaturalLanguageSupport),
false,
),
AgentModalityCalloutState::NaturalLanguageSupport => {
AgentModalityCalloutState::TerminalMode => {
// For Terminal intention, finish here
// For Agent intention, continue to IntroducingAgentExperience
match self.intention {
@@ -203,17 +196,11 @@ impl OnboardingCalloutModel {
),
OnboardingIntention::AgentDrivenDevelopment => {
// Signal to enter agent modality when showing the agent experience slide
(
Some(AgentModalityCalloutState::IntroducingAgentExperience),
true,
)
(Some(AgentModalityCalloutState::AgentMode), true)
}
}
}
AgentModalityCalloutState::IntroducingAgentExperience => {
(Some(AgentModalityCalloutState::UpdatedAgentInput), false)
}
AgentModalityCalloutState::UpdatedAgentInput => {
AgentModalityCalloutState::AgentMode => {
// For Agent with project: Initialize
// For Agent without project: Finish
let final_state = if self.has_project {
@@ -246,7 +233,9 @@ impl OnboardingCalloutModel {
ctx,
);
}
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::UpdatedAgentInput) => {
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::AgentMode)
if self.has_project =>
{
// Skip initialization
self.set_state(
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::Complete(
@@ -272,9 +261,7 @@ impl OnboardingCalloutModel {
ctx,
);
}
OnboardingCalloutState::AgentModality(
AgentModalityCalloutState::NaturalLanguageSupport,
) => {
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::TerminalMode) => {
// Terminal intention finishes here
self.set_state(
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::Complete(
@@ -283,7 +270,9 @@ impl OnboardingCalloutModel {
ctx,
);
}
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::UpdatedAgentInput) => {
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::AgentMode)
if !self.has_project =>
{
// Agent without project finishes here
self.set_state(
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::Complete(
@@ -296,10 +285,10 @@ impl OnboardingCalloutModel {
}
}
/// Handle "Back to terminal" action (ESC in UpdatedAgentInput without project)
/// Handle "Back to terminal" action (ESC in IntroducingAgentExperience).
pub fn back_to_terminal(&mut self, ctx: &mut ModelContext<Self>) {
match &self.state {
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::UpdatedAgentInput) => {
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::AgentMode) => {
self.set_state(
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::Complete(
FinalState::BackToTerminal,
@@ -342,17 +331,11 @@ impl OnboardingCalloutModel {
OnboardingCalloutState::UniversalInput(UniversalInputCalloutState::TalkToAgent) => {
Some("talk_to_agent")
}
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::MeetTerminalInput) => {
Some("meet_terminal_input")
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::TerminalMode) => {
Some("natural_language_support")
}
OnboardingCalloutState::AgentModality(
AgentModalityCalloutState::NaturalLanguageSupport,
) => Some("natural_language_support"),
OnboardingCalloutState::AgentModality(
AgentModalityCalloutState::IntroducingAgentExperience,
) => Some("introducing_agent_experience"),
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::UpdatedAgentInput) => {
Some("updated_agent_input")
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::AgentMode) => {
Some("introducing_agent_experience")
}
_ => None,
};
@@ -435,16 +418,10 @@ impl OnboardingCalloutModel {
fn prompt_for_agent_modality(&self, state: AgentModalityCalloutState) -> OnboardingQuery {
match state {
AgentModalityCalloutState::Off => OnboardingQuery::None,
AgentModalityCalloutState::MeetTerminalInput => {
AgentModalityCalloutState::TerminalMode => {
OnboardingQuery::TerminalCommand("Run a command...".to_string())
}
AgentModalityCalloutState::NaturalLanguageSupport => {
OnboardingQuery::AgentPrompt("help me terraform my Gcloud setup".to_string())
}
AgentModalityCalloutState::IntroducingAgentExperience => {
OnboardingQuery::AgentPrompt("Tell the agent what to build...".to_string())
}
AgentModalityCalloutState::UpdatedAgentInput => {
AgentModalityCalloutState::AgentMode => {
if self.has_project {
OnboardingQuery::AgentPrompt("/init".to_string())
} else {
@@ -470,11 +447,9 @@ impl OnboardingCalloutModel {
);
}
OnboardingCalloutState::AgentModality(_) => {
log::info!("Transitioning to AgentModality::MeetTerminalInput");
log::info!("Transitioning to AgentModality::NaturalLanguageSupport");
self.set_state(
OnboardingCalloutState::AgentModality(
AgentModalityCalloutState::MeetTerminalInput,
),
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::TerminalMode),
ctx,
);
}
+33 -64
View File
@@ -1,11 +1,12 @@
use ui_components::Component;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::{
elements::Empty,
keymap::{macros::*, FixedBinding, Keystroke},
use galaxyui_core::elements::Empty;
use galaxyui_core::keymap::macros::*;
use galaxyui_core::keymap::{FixedBinding, Keystroke};
use galaxyui_core::{
AppContext, Element, Entity, EventContext, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext,
};
use ui_components::Component;
/// Display strings for keybindings shown in the onboarding callout.
#[derive(Clone, Debug)]
@@ -16,16 +17,16 @@ pub struct OnboardingKeybindings {
pub submit_to_local_agent: String,
/// Display string for submitting to cloud agent (e.g., "⌘⌥⏎")
pub submit_to_cloud_agent: String,
/// Display string for returning to terminal mode (e.g., "Esc")
pub return_to_terminal_mode: String,
}
use crate::{
callout::model::{
AgentModalityCalloutState, FinalState, OnboardingCalloutModel, OnboardingCalloutModelEvent,
OnboardingCalloutState, OnboardingQuery, UniversalInputCalloutState,
},
components::onboarding_callout::{self, Button, StepStatus},
OnboardingIntention,
use crate::callout::model::{
AgentModalityCalloutState, FinalState, OnboardingCalloutModel, OnboardingCalloutModelEvent,
OnboardingCalloutState, OnboardingQuery, UniversalInputCalloutState,
};
use crate::components::onboarding_callout::{self, Button, StepStatus};
use crate::OnboardingIntention;
/// Options for rendering a callout.
struct CalloutOptions {
@@ -105,46 +106,23 @@ fn get_agent_modality_callout_options(
keybindings: &OnboardingKeybindings,
) -> Option<CalloutOptions> {
let total_steps = match intention {
OnboardingIntention::Terminal => 2,
OnboardingIntention::AgentDrivenDevelopment => 4,
OnboardingIntention::Terminal => 1,
OnboardingIntention::AgentDrivenDevelopment => 2,
};
match state {
AgentModalityCalloutState::MeetTerminalInput => {
let title: &'static str = if has_project || intention == OnboardingIntention::Terminal {
"Meet your terminal input"
} else {
"Meet your updated terminal input"
};
Some(CalloutOptions {
title,
text: format!(
"Run commands from the terminal, or use {} or {} to start or send to a local or cloud agent respectively.",
keybindings.submit_to_local_agent,
keybindings.submit_to_cloud_agent
),
step: StepStatus::new(0, total_steps),
left_button: None,
right_button: ButtonOptions {
text: "Next",
action: OnboardingCalloutViewAction::NextClicked,
keystroke: Some(Keystroke::parse("enter").unwrap_or_default()),
},
checkbox: None,
})
}
AgentModalityCalloutState::NaturalLanguageSupport => {
AgentModalityCalloutState::TerminalMode => {
let is_final_step = intention == OnboardingIntention::Terminal;
// Show different callout content based on initial NL detection state
if initial_natural_language_detection_enabled {
// NL detection was already enabled - show simpler "overrides" callout without checkbox
Some(CalloutOptions {
title: "Natural language overrides",
title: "Welcome to terminal mode",
text: format!(
"You can always override any auto-detection using {}.",
"Run commands here, just like a regular terminal. If you type a question or task using natural language, Warp can suggest opening it in agent mode. You can always override using {}.",
keybindings.toggle_input_mode
),
step: StepStatus::new(1, total_steps),
step: StepStatus::new(0, total_steps),
left_button: None,
right_button: ButtonOptions {
text: if is_final_step { "Finish" } else { "Next" },
@@ -156,12 +134,12 @@ fn get_agent_modality_callout_options(
} else {
// NL detection was disabled - show full explanation with checkbox to enable
Some(CalloutOptions {
title: "Natural language support",
title: "Youre in terminal mode",
text: format!(
"Natural language input is off by default. If enabled, you can type requests in plain English and Warp will autodetect queries for the agent. You can always override them using {}.",
"Run commands here, just like a regular terminal. If you type a question or task using natural language, Warp can suggest opening it in agent mode. You can always override using {}.",
keybindings.toggle_input_mode
),
step: StepStatus::new(1, total_steps),
step: StepStatus::new(0, total_steps),
left_button: None,
right_button: ButtonOptions {
text: if is_final_step { "Finish" } else { "Next" },
@@ -175,24 +153,12 @@ fn get_agent_modality_callout_options(
})
}
}
AgentModalityCalloutState::IntroducingAgentExperience => Some(CalloutOptions {
title: "Introducing Warp's new agent experience",
text: "Agent conversations are now their own scoped view outside of your terminal. Simply hit ESC to return to the terminal at any point.".to_string(),
step: StepStatus::new(2, total_steps),
left_button: None,
right_button: ButtonOptions {
text: "Next",
action: OnboardingCalloutViewAction::NextClicked,
keystroke: Some(Keystroke::parse("enter").unwrap_or_default()),
},
checkbox: None,
}),
AgentModalityCalloutState::UpdatedAgentInput => {
AgentModalityCalloutState::AgentMode => {
if has_project {
Some(CalloutOptions {
title: "Updated agent input",
text: "Your agent input will detect natural language as well as commands by default. Use ! to lock the input in bash mode to write commands.\n\nSubmit the query below to have the agent initialize this project, or ⊗ to clear the input and start your own!".to_string(),
step: StepStatus::new(3, total_steps),
title: "You're in agent mode",
text: "Agent mode gives your questions and tasks their own conversation, so you can ask follow-ups without leaving your terminal workflow.\n\nSubmit the query below to have the agent initialize this project, or ⊗ to clear the input and start your own!".to_string(),
step: StepStatus::new(1, total_steps),
left_button: Some(ButtonOptions {
text: "Skip initialization",
action: OnboardingCalloutViewAction::SkipClicked,
@@ -207,9 +173,12 @@ fn get_agent_modality_callout_options(
})
} else {
Some(CalloutOptions {
title: "Updated agent input",
text: "Your agent input will detect natural language as well as commands by default. Use ! to lock the input in bash mode to write commands.".to_string(),
step: StepStatus::new(3, total_steps),
title: "You're in agent mode",
text: format!(
"Agent mode gives your questions and tasks their own conversation, so you can ask follow-ups without leaving your terminal workflow. Press {} to return to terminal mode at any point.",
keybindings.return_to_terminal_mode
),
step: StepStatus::new(1, total_steps),
left_button: Some(ButtonOptions {
text: "Back to terminal",
action: OnboardingCalloutViewAction::BackToTerminalClicked,
@@ -378,11 +347,11 @@ impl OnboardingCalloutView {
}
/// Returns true if the callout should be positioned above the zero state.
/// For UpdatedAgentInput state, always position relative to the input box instead.
/// For the agent experience state, always position relative to the input box instead.
pub fn should_position_above_zero_state(&self, app: &AppContext) -> bool {
!matches!(
self.model.as_ref(app).state(),
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::UpdatedAgentInput)
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::AgentMode)
)
}
@@ -0,0 +1,141 @@
use pathfinder_color::ColorU;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::Fill;
use galaxy_core::ui::Icon;
use galaxyui_core::elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex,
FormattedTextElement, MainAxisAlignment, MainAxisSize, ParentElement, Radius, Shrinkable,
};
use galaxyui_core::fonts::Weight;
use galaxyui_core::text_layout::TextAlignment;
use galaxyui_core::Element;
/// Content for a "you'll lose these features" opt-out confirmation dialog.
pub struct FeatureOptOutDialog {
pub title: &'static str,
pub body: &'static str,
pub features: &'static [&'static str],
pub close_button: Box<dyn Element>,
pub cancel_button: Box<dyn Element>,
pub confirm_button: Box<dyn Element>,
}
pub fn render_feature_optout_dialog(
appearance: &Appearance,
dialog: FeatureOptOutDialog,
) -> Box<dyn Element> {
let theme = appearance.theme();
let dialog_surface = theme.surface_1();
let dialog_surface_solid = dialog_surface.into_solid();
let border_color = internal_colors::neutral_4(theme);
let title = FormattedTextElement::from_str(dialog.title, appearance.ui_font_family(), 16.)
.with_color(internal_colors::text_main(theme, dialog_surface_solid))
.with_weight(Weight::Bold)
.with_line_height_ratio(1.25)
.finish();
let title_row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(Shrinkable::new(1., title).finish())
.with_child(dialog.close_button)
.finish();
let body_text = FormattedTextElement::from_str(dialog.body, appearance.ui_font_family(), 14.)
.with_color(internal_colors::text_main(theme, dialog_surface_solid))
.with_weight(Weight::Normal)
.with_line_height_ratio(1.2)
.finish();
let mut body_section = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(body_text);
// The list is optional: callers that only want a warning (e.g. the no-AI
// onboarding modal) pass an empty slice, in which case we skip it entirely.
if !dialog.features.is_empty() {
let feature_row_color: ColorU = theme.foreground().into();
let feature_x_fill = Fill::Solid(theme.ansi_fg_red());
let mut feature_list =
Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
for &item in dialog.features {
let icon_el = ConstrainedBox::new(Icon::X.to_warpui_icon(feature_x_fill).finish())
.with_width(16.)
.with_height(16.)
.finish();
let text_el = FormattedTextElement::from_str(item, appearance.ui_font_family(), 14.)
.with_color(feature_row_color)
.with_weight(Weight::Normal)
.with_alignment(TextAlignment::Left)
.with_line_height_ratio(1.0)
.finish();
let row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(icon_el)
.with_child(Container::new(text_el).with_margin_left(4.).finish())
.finish();
feature_list = feature_list.with_child(
Container::new(row)
.with_padding_top(4.)
.with_padding_bottom(4.)
.finish(),
);
}
body_section = body_section.with_child(
Container::new(feature_list.finish())
.with_margin_top(12.)
.finish(),
);
}
let body_section = body_section.finish();
let footer = Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::End)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(dialog.cancel_button)
.with_child(
Container::new(dialog.confirm_button)
.with_margin_left(8.)
.finish(),
)
.finish(),
)
.with_border(Border::top(1.).with_border_color(border_color))
.with_horizontal_padding(24.)
.with_vertical_padding(12.)
.finish();
let dialog_body = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(
Container::new(title_row)
.with_horizontal_padding(24.)
.with_padding_top(24.)
.with_padding_bottom(12.)
.finish(),
)
.with_child(
Container::new(body_section)
.with_horizontal_padding(24.)
.with_padding_bottom(16.)
.finish(),
)
.with_child(footer)
.finish();
ConstrainedBox::new(
Container::new(dialog_body)
.with_background(dialog_surface)
.with_border(Border::all(1.).with_border_color(border_color))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish(),
)
.with_width(460.)
.finish()
}
+1
View File
@@ -1 +1,2 @@
pub mod feature_optout_dialog;
pub mod onboarding_callout;
@@ -1,26 +1,23 @@
use std::borrow::Cow;
use galaxy_core::ui::{
appearance::Appearance,
color::{coloru_with_opacity, contrast::relative_luminance},
theme::{phenomenon::PhenomenonStyle, Fill},
};
use galaxyui::{
elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DropShadow, Flex,
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Rect,
},
fonts::Weight,
keymap::Keystroke,
prelude::*,
ui_components::checkbox::Checkbox as WarpCheckbox,
ui_components::components::{UiComponent as _, UiComponentStyles},
};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use ui_components::{
button, button::Button as ButtonComponent, Component, MouseEventHandler, Options as _,
use ui_components::button::Button as ButtonComponent;
use ui_components::{button, Component, MouseEventHandler, Options as _};
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::color::coloru_with_opacity;
use galaxy_core::ui::color::contrast::relative_luminance;
use galaxy_core::ui::theme::phenomenon::PhenomenonStyle;
use galaxy_core::ui::theme::Fill;
use galaxyui_core::elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DropShadow, Flex,
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Rect,
};
use galaxyui_core::fonts::Weight;
use galaxyui_core::keymap::Keystroke;
use galaxyui_core::prelude::*;
use galaxyui_core::ui_components::checkbox::Checkbox as WarpCheckbox;
use galaxyui_core::ui_components::components::{UiComponent as _, UiComponentStyles};
const CALLOUT_WIDTH: f32 = 480.;
const CALLOUT_BORDER_WIDTH: f32 = 1.;
+8 -9
View File
@@ -24,17 +24,16 @@ impl std::fmt::Display for OnboardingIntention {
pub use callout::{OnboardingCalloutView, OnboardingKeybindings};
/// User-facing names of the AI features enabled when the agent intention is selected.
/// User-facing descriptions of the AI features enabled when the agent intention is selected.
/// Shared by the intention slide's agent card checklist and the login slide's
/// skip-login confirmation dialog so the two always stay in sync.
pub const AI_FEATURES: &[&str] = &[
"Warp agents",
"Oz cloud agents platform",
"Next command predictions",
"Prompt suggestions",
"Codebase context",
"Remote control with Claude Code, Codex, and other agents",
"Agents over SSH",
"Use frontier and open-weight models with Warp Agent",
"Hand off agent work to cloud agents",
"Automatically diagnose and fix terminal errors",
"Agentic control of long-running commands and TUIs",
"Review code diffs and send comments directly to agents",
"Remote control for Claude Code, Codex, and other agents",
];
/// User-facing names of the Warp Drive features enabled when the terminal
@@ -76,7 +75,7 @@ pub use model::{OnboardingAuthState, SelectedSettings, UICustomizationSettings};
pub use slides::ProjectOnboardingSettings;
pub use telemetry::OnboardingEvent;
pub fn init(app: &mut galaxyui::AppContext) {
pub fn init(app: &mut galaxyui_core::AppContext) {
agent_onboarding_view::init(app);
callout::init(app);
}
+316 -81
View File
@@ -1,11 +1,12 @@
use ai::LLMId;
use galaxy_core::send_telemetry_from_ctx;
use galaxyui_core::{Entity, ModelContext};
use crate::slides::{
AgentAutonomy, AgentDevelopmentSettings, OnboardingModelInfo, ProjectOnboardingSettings,
};
use crate::telemetry::OnboardingEvent;
use crate::OnboardingIntention;
use ai::LLMId;
use galaxy_core::send_telemetry_from_ctx;
use galaxyui::{Entity, ModelContext};
/// UI customization settings chosen during the "Customize your UI" onboarding slide.
#[derive(Clone, Debug)]
@@ -80,9 +81,12 @@ impl SelectedSettings {
pub fn is_ai_enabled(&self) -> bool {
use galaxy_core::features::FeatureFlag;
match self {
SelectedSettings::AgentDrivenDevelopment { agent_settings, .. } => {
!agent_settings.disable_oz
}
// Agent-driven development always means "I want AI" (including the
// bring-your-own-agents `disable_oz` path). This reflects intent and
// is used to decide that an account/login is required; whether AI is
// actually enabled is applied later based on whether the user has an
// account (see `apply_onboarding_settings`).
SelectedSettings::AgentDrivenDevelopment { .. } => true,
SelectedSettings::Terminal { .. } => {
// With old onboarding (no OpenWarpNewSettingsModes), Terminal
// intent still leaves AI enabled; with new onboarding,
@@ -114,13 +118,57 @@ impl SelectedSettings {
pub(crate) enum OnboardingStep {
Intro,
Intention,
AiSetup,
Customize,
Agent,
AiAccess,
ThirdParty,
Project,
ThemePicker,
}
/// The AI setup selected on the "Choose your AI setup" slide.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AiSetupChoice {
#[default]
WarpAgent,
ThirdParty,
}
impl std::fmt::Display for AiSetupChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AiSetupChoice::WarpAgent => write!(f, "warp_agent"),
AiSetupChoice::ThirdParty => write!(f, "third_party"),
}
}
}
/// The access method selected on the "Choose how to access AI" slide (Warp Agent path).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AiAccessChoice {
#[default]
Subscription,
SetUpLater,
}
impl std::fmt::Display for AiAccessChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AiAccessChoice::Subscription => write!(f, "subscription"),
AiAccessChoice::SetUpLater => write!(f, "set_up_later"),
}
}
}
/// Which opt-out entry point opened the "Are you sure you don't want AI?" modal.
/// Determines where "Give me AI features" routes the user on cancel.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum NoAiConfirmationSource {
/// Triggered from the intention slide via "Just use the terminal" + Next.
Intention,
}
#[derive(Clone, Debug)]
pub(crate) enum OnboardingStateEvent {
ModelsUpdated,
@@ -129,6 +177,7 @@ pub(crate) enum OnboardingStateEvent {
Completed,
UpgradeRequested,
AuthStateChanged,
NoAiConfirmationChanged,
}
#[derive(Clone, Debug)]
@@ -143,15 +192,15 @@ pub(crate) struct OnboardingStateModel {
workspace_enforces_autonomy: bool,
/// Whether the AgentView feature flag is enabled.
agent_modality_enabled: bool,
/// Whether the user is in the FreeUserNoAi experiment group (and is free tier).
/// When true, the Agent Driven Development option on the intention slide is locked
/// behind an upgrade CTA.
free_user_no_ai_experiment: bool,
/// Yearly price per month in USD cents for the agent plan badge.
/// When `None`, falls back to a hardcoded default ($18/mo).
agent_price_cents: Option<i32>,
/// The AI setup selected on the "Choose your AI setup" slide.
ai_setup_choice: AiSetupChoice,
/// The access method selected on the "Choose how to access AI" slide.
ai_access_choice: AiAccessChoice,
/// Auth / billing state of the user.
auth_state: OnboardingAuthState,
/// When set, the "Are you sure you don't want AI?" confirmation modal is
/// shown; the value records which entry point triggered it.
no_ai_confirmation: Option<NoAiConfirmationSource>,
}
impl OnboardingStateModel {
@@ -161,8 +210,6 @@ impl OnboardingStateModel {
default_model_id: LLMId,
workspace_enforces_autonomy: bool,
agent_modality_enabled: bool,
free_user_no_ai_experiment: bool,
agent_price_cents: Option<i32>,
auth_state: OnboardingAuthState,
) -> Self {
Self {
@@ -174,9 +221,10 @@ impl OnboardingStateModel {
models,
workspace_enforces_autonomy,
agent_modality_enabled,
free_user_no_ai_experiment,
agent_price_cents,
ai_setup_choice: AiSetupChoice::default(),
ai_access_choice: AiAccessChoice::default(),
auth_state,
no_ai_confirmation: None,
}
}
@@ -197,7 +245,6 @@ impl OnboardingStateModel {
}
pub(crate) fn settings(&self) -> SelectedSettings {
use galaxy_core::features::FeatureFlag;
let ui_customization = if FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
Some(self.ui_customization.clone())
} else {
@@ -256,6 +303,111 @@ impl OnboardingStateModel {
self.agent_modality_enabled
}
/// Whether the DES-816 V3 onboarding flow (the "Choose your AI setup" fork on the
/// AI-first path) is active. True for all users when the new settings-modes flow
/// is enabled, since new users always enter a world where Warp-provided AI is not free.
pub(crate) fn ai_setup_flow_active(&self) -> bool {
FeatureFlag::OpenWarpNewSettingsModes.is_enabled()
}
pub(crate) fn ai_setup_choice(&self) -> AiSetupChoice {
self.ai_setup_choice
}
pub(crate) fn ai_access_choice(&self) -> AiAccessChoice {
self.ai_access_choice
}
pub(crate) fn set_ai_setup_choice(
&mut self,
choice: AiSetupChoice,
ctx: &mut ModelContext<Self>,
) {
if self.ai_setup_choice == choice {
return;
}
send_telemetry_from_ctx!(
OnboardingEvent::SettingChanged {
setting: "ai_setup".to_string(),
value: choice.to_string(),
},
ctx
);
self.ai_setup_choice = choice;
self.agent_settings.disable_oz = matches!(choice, AiSetupChoice::ThirdParty);
ctx.notify();
}
pub(crate) fn set_ai_access_choice(
&mut self,
choice: AiAccessChoice,
ctx: &mut ModelContext<Self>,
) {
if self.ai_access_choice == choice {
return;
}
send_telemetry_from_ctx!(
OnboardingEvent::SettingChanged {
setting: "ai_access".to_string(),
value: choice.to_string(),
},
ctx
);
self.ai_access_choice = choice;
ctx.notify();
}
pub(crate) fn no_ai_confirmation(&self) -> Option<NoAiConfirmationSource> {
self.no_ai_confirmation
}
/// Shows the "Are you sure you don't want AI?" confirmation modal, recording
/// which opt-out entry point triggered it so cancel can route appropriately.
pub(crate) fn request_no_ai_confirmation(
&mut self,
source: NoAiConfirmationSource,
ctx: &mut ModelContext<Self>,
) {
send_telemetry_from_ctx!(OnboardingEvent::NoAiConfirmationShown, ctx);
self.no_ai_confirmation = Some(source);
ctx.emit(OnboardingStateEvent::NoAiConfirmationChanged);
ctx.notify();
}
/// "I don't want AI": commit to the terminal-only path (AI features off) and
/// continue the flow there, so declining AI never dead-ends onboarding.
pub(crate) fn confirm_no_ai(&mut self, ctx: &mut ModelContext<Self>) {
send_telemetry_from_ctx!(OnboardingEvent::NoAiConfirmed, ctx);
self.no_ai_confirmation = None;
self.set_intention(OnboardingIntention::Terminal, ctx);
self.set_step(OnboardingStep::Customize, ctx);
}
/// "Give me AI features": abort the opt-out. The only trigger is the
/// intention slide's "Just use the terminal", which is an explicit request
/// for AI, so route onto the AI path.
pub(crate) fn cancel_no_ai(&mut self, ctx: &mut ModelContext<Self>) {
send_telemetry_from_ctx!(OnboardingEvent::NoAiConfirmationCancelled, ctx);
match self.no_ai_confirmation.take() {
Some(NoAiConfirmationSource::Intention) => {
self.set_intention(OnboardingIntention::AgentDrivenDevelopment, ctx);
self.set_step(OnboardingStep::AiSetup, ctx);
}
None => {
ctx.emit(OnboardingStateEvent::NoAiConfirmationChanged);
ctx.notify();
}
}
}
/// Closes the confirmation modal without changing the user's path (ESC / X).
pub(crate) fn dismiss_no_ai(&mut self, ctx: &mut ModelContext<Self>) {
if self.no_ai_confirmation.take().is_some() {
ctx.emit(OnboardingStateEvent::NoAiConfirmationChanged);
ctx.notify();
}
}
pub fn ui_customization(&self) -> &UICustomizationSettings {
&self.ui_customization
}
@@ -290,28 +442,6 @@ impl OnboardingStateModel {
ctx.notify();
}
pub(crate) fn free_user_no_ai_experiment(&self) -> bool {
self.free_user_no_ai_experiment
}
pub(crate) fn agent_price_badge(&self) -> String {
const DEFAULT_AGENT_PRICE_CENTS: i32 = 1800;
let cents = self.agent_price_cents.unwrap_or(DEFAULT_AGENT_PRICE_CENTS);
format!("Starting at ${}/mo", cents / 100)
}
pub(crate) fn set_agent_price_cents(
&mut self,
cents: Option<i32>,
ctx: &mut ModelContext<Self>,
) {
if self.agent_price_cents == cents {
return;
}
self.agent_price_cents = cents;
ctx.notify();
}
pub(crate) fn set_show_conversation_history(
&mut self,
value: bool,
@@ -448,18 +578,6 @@ impl OnboardingStateModel {
ctx.notify();
}
pub(crate) fn set_free_user_no_ai_experiment(
&mut self,
value: bool,
ctx: &mut ModelContext<Self>,
) {
if self.free_user_no_ai_experiment == value {
return;
}
self.free_user_no_ai_experiment = value;
ctx.notify();
}
pub(crate) fn set_workspace_enforces_autonomy(
&mut self,
value: bool,
@@ -512,13 +630,6 @@ impl OnboardingStateModel {
self.set_intention(OnboardingIntention::AgentDrivenDevelopment, ctx);
}
pub(crate) fn is_model_disabled(&self, model_id: &LLMId) -> bool {
self.models
.iter()
.find(|m| &m.id == model_id)
.is_some_and(|m| m.requires_upgrade)
}
pub(crate) fn request_upgrade(&mut self, ctx: &mut ModelContext<Self>) {
ctx.emit(OnboardingStateEvent::UpgradeRequested);
}
@@ -528,10 +639,6 @@ impl OnboardingStateModel {
return;
}
if self.is_model_disabled(&model_id) {
return;
}
send_telemetry_from_ctx!(
OnboardingEvent::SettingChanged {
setting: "model".to_string(),
@@ -551,7 +658,6 @@ impl OnboardingStateModel {
default_model_id: LLMId,
ctx: &mut ModelContext<Self>,
) {
use galaxy_core::features::FeatureFlag;
// If the user is past the agent slide, don't change the agent model from underneath them.
// When the new settings modes flag is on, ThemePicker comes after the agent slides
@@ -631,12 +737,13 @@ impl OnboardingStateModel {
}
fn send_completion_telemetry(&self, ctx: &mut ModelContext<Self>) {
let (intention, model, autonomy) = match &self.intention {
OnboardingIntention::Terminal => (self.intention.to_string(), None, None),
let (intention, model, autonomy, ai_access) = match &self.intention {
OnboardingIntention::Terminal => (self.intention.to_string(), None, None, None),
OnboardingIntention::AgentDrivenDevelopment => (
self.intention.to_string(),
Some(self.agent_settings.selected_model_id.to_string()),
self.agent_settings.autonomy.map(|x| x.to_string()),
Some(self.ai_setup_choice.to_string()),
),
};
@@ -651,6 +758,7 @@ impl OnboardingStateModel {
model,
autonomy,
has_project_path,
ai_access,
},
ctx
);
@@ -663,27 +771,45 @@ impl OnboardingStateModel {
}
pub(crate) fn back(&mut self, ctx: &mut ModelContext<Self>) {
use galaxy_core::features::FeatureFlag;
let theme_picker_last = FeatureFlag::OpenWarpNewSettingsModes.is_enabled();
let ai_setup_flow = self.ai_setup_flow_active();
let agent_intention = matches!(self.intention, OnboardingIntention::AgentDrivenDevelopment);
let prev = if theme_picker_last {
match self.step {
OnboardingStep::Intro => None,
OnboardingStep::Intention => Some(OnboardingStep::Intro),
OnboardingStep::Customize => Some(OnboardingStep::Intention),
OnboardingStep::Agent => Some(OnboardingStep::Customize),
OnboardingStep::ThirdParty => match self.intention {
OnboardingIntention::Terminal => Some(OnboardingStep::Customize),
OnboardingIntention::AgentDrivenDevelopment => Some(OnboardingStep::Agent),
},
OnboardingStep::AiSetup => Some(OnboardingStep::Intention),
OnboardingStep::Customize => {
if ai_setup_flow && agent_intention {
match self.ai_setup_choice {
AiSetupChoice::WarpAgent => Some(OnboardingStep::AiAccess),
AiSetupChoice::ThirdParty => Some(OnboardingStep::ThirdParty),
}
} else {
Some(OnboardingStep::Intention)
}
}
OnboardingStep::AiAccess => Some(OnboardingStep::Agent),
OnboardingStep::Agent => {
if ai_setup_flow {
Some(OnboardingStep::AiSetup)
} else {
Some(OnboardingStep::Customize)
}
}
OnboardingStep::ThirdParty => Some(OnboardingStep::AiSetup),
OnboardingStep::Project => Some(OnboardingStep::ThirdParty),
OnboardingStep::ThemePicker => Some(OnboardingStep::ThirdParty),
OnboardingStep::ThemePicker => Some(OnboardingStep::Customize),
}
} else {
match self.step {
OnboardingStep::Intro => None,
OnboardingStep::ThemePicker => Some(OnboardingStep::Intro),
OnboardingStep::Intention => Some(OnboardingStep::ThemePicker),
// Unreachable in the legacy flow.
OnboardingStep::AiSetup => None,
OnboardingStep::AiAccess => None,
OnboardingStep::Customize => None,
OnboardingStep::ThirdParty => None,
OnboardingStep::Agent => Some(OnboardingStep::Intention),
@@ -698,7 +824,6 @@ impl OnboardingStateModel {
}
pub(crate) fn next(&mut self, ctx: &mut ModelContext<Self>) {
use galaxy_core::features::FeatureFlag;
let theme_picker_last = FeatureFlag::OpenWarpNewSettingsModes.is_enabled();
let is_last_step = if theme_picker_last {
@@ -711,17 +836,52 @@ impl OnboardingStateModel {
}
if theme_picker_last {
let ai_setup_flow = self.ai_setup_flow_active();
match self.step {
OnboardingStep::Intro => self.set_step(OnboardingStep::Intention, ctx),
OnboardingStep::Intention => self.set_step(OnboardingStep::Customize, ctx),
OnboardingStep::Customize => match self.intention {
OnboardingIntention::Terminal => self.set_step(OnboardingStep::ThirdParty, ctx),
OnboardingStep::Intention => match self.intention {
OnboardingIntention::Terminal => self.set_step(OnboardingStep::Customize, ctx),
OnboardingIntention::AgentDrivenDevelopment => {
self.set_step(OnboardingStep::Agent, ctx)
if ai_setup_flow {
self.set_step(OnboardingStep::AiSetup, ctx)
} else {
self.set_step(OnboardingStep::Customize, ctx)
}
}
},
OnboardingStep::Agent => self.set_step(OnboardingStep::ThirdParty, ctx),
OnboardingStep::ThirdParty => self.set_step(OnboardingStep::ThemePicker, ctx),
OnboardingStep::AiSetup => match self.ai_setup_choice {
AiSetupChoice::WarpAgent => self.set_step(OnboardingStep::Agent, ctx),
AiSetupChoice::ThirdParty => self.set_step(OnboardingStep::ThirdParty, ctx),
},
OnboardingStep::Customize => match self.intention {
OnboardingIntention::Terminal => {
self.set_step(OnboardingStep::ThemePicker, ctx)
}
OnboardingIntention::AgentDrivenDevelopment => {
if ai_setup_flow {
self.set_step(OnboardingStep::ThemePicker, ctx)
} else {
self.set_step(OnboardingStep::Agent, ctx)
}
}
},
OnboardingStep::Agent => {
if ai_setup_flow {
self.set_step(OnboardingStep::AiAccess, ctx)
} else {
self.set_step(OnboardingStep::ThirdParty, ctx)
}
}
OnboardingStep::AiAccess => self.set_step(OnboardingStep::Customize, ctx),
OnboardingStep::ThirdParty => {
if ai_setup_flow
&& matches!(self.intention, OnboardingIntention::AgentDrivenDevelopment)
{
self.set_step(OnboardingStep::Customize, ctx)
} else {
self.set_step(OnboardingStep::ThemePicker, ctx)
}
}
OnboardingStep::Project => self.set_step(OnboardingStep::ThemePicker, ctx),
OnboardingStep::ThemePicker => {}
}
@@ -730,6 +890,9 @@ impl OnboardingStateModel {
OnboardingStep::Intro => self.set_step(OnboardingStep::ThemePicker, ctx),
OnboardingStep::ThemePicker => self.set_step(OnboardingStep::Intention, ctx),
OnboardingStep::Intention => self.set_step(OnboardingStep::Agent, ctx),
// Unreachable in the legacy flow.
OnboardingStep::AiSetup => {}
OnboardingStep::AiAccess => {}
OnboardingStep::Customize => {}
OnboardingStep::ThirdParty => {}
OnboardingStep::Agent => self.set_step(OnboardingStep::Project, ctx),
@@ -770,6 +933,22 @@ impl OnboardingStateModel {
ctx
);
}
OnboardingStep::AiSetup => {
send_telemetry_from_ctx!(
OnboardingEvent::SlideViewed {
slide_name: "ai_setup".to_string(),
},
ctx
);
}
OnboardingStep::AiAccess => {
send_telemetry_from_ctx!(
OnboardingEvent::SlideViewed {
slide_name: "ai_access".to_string(),
},
ctx
);
}
OnboardingStep::Customize => {
send_telemetry_from_ctx!(
OnboardingEvent::SlideViewed {
@@ -807,8 +986,64 @@ impl OnboardingStateModel {
ctx.emit(OnboardingStateEvent::SelectedSlideChanged);
ctx.notify();
}
/// The `(step_index, step_count)` shown by the bottom-nav progress dots for the
/// current step, intention, and flow variant.
pub(crate) fn progress(&self) -> (usize, usize) {
let is_terminal = matches!(self.intention, OnboardingIntention::Terminal);
if !FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
// Legacy flow: ThemePicker → Intention → Agent → Project.
return match self.step {
OnboardingStep::Intro | OnboardingStep::ThemePicker => (0, 4),
OnboardingStep::Intention | OnboardingStep::AiSetup | OnboardingStep::Customize => {
(1, 4)
}
OnboardingStep::Agent | OnboardingStep::ThirdParty | OnboardingStep::AiAccess => {
(2, 4)
}
OnboardingStep::Project => (3, 4),
};
}
// The Warp Agent path has the extra "Choose how to access AI" step, so it
// is one longer than the third-party-agent path.
let is_warp_agent_path =
!is_terminal && matches!(self.ai_setup_choice, AiSetupChoice::WarpAgent);
let step_count = if is_terminal {
3
} else if is_warp_agent_path {
6
} else {
5
};
let step_index = match self.step {
OnboardingStep::Intro | OnboardingStep::Intention => 0,
OnboardingStep::AiSetup => 1,
OnboardingStep::Agent => 2,
OnboardingStep::AiAccess => 3,
OnboardingStep::Customize => {
if is_terminal {
1
} else if is_warp_agent_path {
4
} else {
3
}
}
OnboardingStep::ThirdParty => 2,
// Unreachable in the new flow; keep the legacy position.
OnboardingStep::Project => 3,
OnboardingStep::ThemePicker => step_count - 1,
};
(step_index, step_count)
}
}
impl Entity for OnboardingStateModel {
type Event = OnboardingStateEvent;
}
#[cfg(test)]
#[path = "model_tests.rs"]
mod tests;
+328
View File
@@ -0,0 +1,328 @@
use ai::LLMId;
use galaxy_core::features::FeatureFlag;
use galaxy_core::telemetry::testing::MockTelemetryContextProvider;
use galaxyui_core::{App, ModelHandle};
use crate::model::{
AiSetupChoice, NoAiConfirmationSource, OnboardingAuthState, OnboardingStateModel,
OnboardingStep, SelectedSettings,
};
use crate::OnboardingIntention;
fn add_test_model(app: &mut App) -> ModelHandle<OnboardingStateModel> {
app.update(MockTelemetryContextProvider::register);
app.add_model(|_| {
OnboardingStateModel::new(
Vec::new(),
LLMId::from("auto"),
false,
true,
OnboardingAuthState::FreeUser,
)
})
}
fn step(app: &App, model: &ModelHandle<OnboardingStateModel>) -> OnboardingStep {
model.read(app, |model, _| model.step())
}
#[test]
fn agent_path_routes_through_ai_setup() {
let _flag = FeatureFlag::OpenWarpNewSettingsModes.override_enabled(true);
App::test((), |mut app| async move {
let model = add_test_model(&mut app);
// Default intention is agent-driven development.
model.update(&mut app, |model, ctx| model.next(ctx));
assert_eq!(step(&app, &model), OnboardingStep::Intention);
model.update(&mut app, |model, ctx| model.next(ctx));
assert_eq!(step(&app, &model), OnboardingStep::AiSetup);
// The default AI setup choice is the Warp agent.
model.update(&mut app, |model, ctx| model.next(ctx));
assert_eq!(step(&app, &model), OnboardingStep::Agent);
model.update(&mut app, |model, ctx| model.next(ctx));
assert_eq!(step(&app, &model), OnboardingStep::AiAccess);
model.update(&mut app, |model, ctx| model.next(ctx));
assert_eq!(step(&app, &model), OnboardingStep::Customize);
model.update(&mut app, |model, ctx| model.next(ctx));
assert_eq!(step(&app, &model), OnboardingStep::ThemePicker);
// Back navigation mirrors the forward path.
model.update(&mut app, |model, ctx| model.back(ctx));
assert_eq!(step(&app, &model), OnboardingStep::Customize);
model.update(&mut app, |model, ctx| model.back(ctx));
assert_eq!(step(&app, &model), OnboardingStep::AiAccess);
model.update(&mut app, |model, ctx| model.back(ctx));
assert_eq!(step(&app, &model), OnboardingStep::Agent);
model.update(&mut app, |model, ctx| model.back(ctx));
assert_eq!(step(&app, &model), OnboardingStep::AiSetup);
model.update(&mut app, |model, ctx| model.back(ctx));
assert_eq!(step(&app, &model), OnboardingStep::Intention);
});
}
#[test]
fn third_party_choice_routes_to_third_party_slide() {
let _flag = FeatureFlag::OpenWarpNewSettingsModes.override_enabled(true);
App::test((), |mut app| async move {
let model = add_test_model(&mut app);
model.update(&mut app, |model, ctx| {
model.next(ctx); // Intro → Intention
model.next(ctx); // Intention → AiSetup
model.set_ai_setup_choice(AiSetupChoice::ThirdParty, ctx);
model.next(ctx); // AiSetup → ThirdParty
});
assert_eq!(step(&app, &model), OnboardingStep::ThirdParty);
model.update(&mut app, |model, ctx| model.next(ctx));
assert_eq!(step(&app, &model), OnboardingStep::Customize);
// Back from Customize returns to the chosen AI-setup slide.
model.update(&mut app, |model, ctx| model.back(ctx));
assert_eq!(step(&app, &model), OnboardingStep::ThirdParty);
model.update(&mut app, |model, ctx| model.back(ctx));
assert_eq!(step(&app, &model), OnboardingStep::AiSetup);
});
}
#[test]
fn confirm_no_ai_switches_to_terminal_path() {
let _flag = FeatureFlag::OpenWarpNewSettingsModes.override_enabled(true);
App::test((), |mut app| async move {
let model = add_test_model(&mut app);
model.update(&mut app, |model, ctx| {
model.next(ctx); // Intro → Intention
model.set_intention_terminal(ctx);
model.request_no_ai_confirmation(NoAiConfirmationSource::Intention, ctx);
});
// The confirmation modal is shown without leaving the intention slide yet.
assert_eq!(step(&app, &model), OnboardingStep::Intention);
model.read(&app, |model, _| {
assert_eq!(
model.no_ai_confirmation(),
Some(NoAiConfirmationSource::Intention)
);
});
// Confirming "I don't want AI" lands on the terminal path, never a dead end.
model.update(&mut app, |model, ctx| model.confirm_no_ai(ctx));
assert_eq!(step(&app, &model), OnboardingStep::Customize);
model.read(&app, |model, _| {
assert_eq!(model.no_ai_confirmation(), None);
assert_eq!(*model.intention(), OnboardingIntention::Terminal);
assert!(!model.settings().is_ai_enabled());
});
// The terminal path continues to completion, skipping the third-party slide.
model.update(&mut app, |model, ctx| model.next(ctx));
assert_eq!(step(&app, &model), OnboardingStep::ThemePicker);
});
}
#[test]
fn confirm_no_ai_from_intention_then_back_returns_to_intention() {
let _flag = FeatureFlag::OpenWarpNewSettingsModes.override_enabled(true);
App::test((), |mut app| async move {
let model = add_test_model(&mut app);
model.update(&mut app, |model, ctx| {
model.next(ctx); // Intro → Intention
model.set_intention_terminal(ctx);
model.request_no_ai_confirmation(NoAiConfirmationSource::Intention, ctx);
});
// "Just use the terminal" + Next does not advance until the user confirms.
assert_eq!(step(&app, &model), OnboardingStep::Intention);
model.update(&mut app, |model, ctx| model.confirm_no_ai(ctx));
assert_eq!(step(&app, &model), OnboardingStep::Customize);
// Back from Customize goes to the intention fork, not the AI-setup slide.
model.update(&mut app, |model, ctx| model.back(ctx));
assert_eq!(step(&app, &model), OnboardingStep::Intention);
});
}
#[test]
fn cancel_no_ai_from_intention_routes_to_ai_setup() {
let _flag = FeatureFlag::OpenWarpNewSettingsModes.override_enabled(true);
App::test((), |mut app| async move {
let model = add_test_model(&mut app);
model.update(&mut app, |model, ctx| {
model.next(ctx); // Intro → Intention
model.set_intention_terminal(ctx);
model.request_no_ai_confirmation(NoAiConfirmationSource::Intention, ctx);
});
// "Give me AI features" switches onto the AI path and opens the AI-setup slide.
model.update(&mut app, |model, ctx| model.cancel_no_ai(ctx));
assert_eq!(step(&app, &model), OnboardingStep::AiSetup);
model.read(&app, |model, _| {
assert_eq!(model.no_ai_confirmation(), None);
assert_eq!(
*model.intention(),
OnboardingIntention::AgentDrivenDevelopment
);
});
});
}
#[test]
fn dismiss_no_ai_closes_without_changing_path() {
let _flag = FeatureFlag::OpenWarpNewSettingsModes.override_enabled(true);
App::test((), |mut app| async move {
let model = add_test_model(&mut app);
model.update(&mut app, |model, ctx| {
model.next(ctx); // Intro → Intention
model.set_intention_terminal(ctx);
model.request_no_ai_confirmation(NoAiConfirmationSource::Intention, ctx);
model.dismiss_no_ai(ctx);
});
assert_eq!(step(&app, &model), OnboardingStep::Intention);
model.read(&app, |model, _| {
assert_eq!(model.no_ai_confirmation(), None);
assert_eq!(*model.intention(), OnboardingIntention::Terminal);
});
});
}
#[test]
fn terminal_settings_disable_ai() {
let _flag = FeatureFlag::OpenWarpNewSettingsModes.override_enabled(true);
App::test((), |mut app| async move {
let model = add_test_model(&mut app);
model.update(&mut app, |model, ctx| model.set_intention_terminal(ctx));
model.read(&app, |model, _| {
assert!(matches!(
model.settings(),
SelectedSettings::Terminal { .. }
));
assert!(!model.settings().is_ai_enabled());
});
});
}
#[test]
fn agent_intent_keeps_ai_enabled_for_any_setup_choice() {
let _flag = FeatureFlag::OpenWarpNewSettingsModes.override_enabled(true);
App::test((), |mut app| async move {
let model = add_test_model(&mut app);
// Default agent intention + "Use Warp Agent" enables AI.
model.read(&app, |model, _| assert!(model.settings().is_ai_enabled()));
// "Use third party agents" still keeps AI enabled: agent intent always
// means the user wants AI, even when bringing their own agents.
model.update(&mut app, |model, ctx| {
model.set_ai_setup_choice(AiSetupChoice::ThirdParty, ctx)
});
model.read(&app, |model, _| assert!(model.settings().is_ai_enabled()));
// Switching back to Warp Agent also keeps AI enabled.
model.update(&mut app, |model, ctx| {
model.set_ai_setup_choice(AiSetupChoice::WarpAgent, ctx)
});
model.read(&app, |model, _| assert!(model.settings().is_ai_enabled()));
});
}
#[test]
fn terminal_path_skips_third_party() {
let _flag = FeatureFlag::OpenWarpNewSettingsModes.override_enabled(true);
App::test((), |mut app| async move {
let model = add_test_model(&mut app);
model.update(&mut app, |model, ctx| model.set_intention_terminal(ctx));
// Terminal goes Intention → Customize → ThemePicker; the "Customize third
// party agents" slide is only for the agent → third-party choice.
for expected in [
OnboardingStep::Intention,
OnboardingStep::Customize,
OnboardingStep::ThemePicker,
] {
model.update(&mut app, |model, ctx| model.next(ctx));
assert_eq!(step(&app, &model), expected);
}
// Back navigation mirrors the forward path, also skipping third-party.
model.update(&mut app, |model, ctx| model.back(ctx));
assert_eq!(step(&app, &model), OnboardingStep::Customize);
model.update(&mut app, |model, ctx| model.back(ctx));
assert_eq!(step(&app, &model), OnboardingStep::Intention);
});
}
#[test]
fn progress_reports_v3_positions_for_agent_path() {
let _flag = FeatureFlag::OpenWarpNewSettingsModes.override_enabled(true);
App::test((), |mut app| async move {
let model = add_test_model(&mut app);
// Warp Agent path: Intention → AiSetup → Agent → AiAccess → Customize → ThemePicker.
let cases = [
(OnboardingStep::Intention, (0, 6)),
(OnboardingStep::AiSetup, (1, 6)),
(OnboardingStep::Agent, (2, 6)),
(OnboardingStep::AiAccess, (3, 6)),
(OnboardingStep::Customize, (4, 6)),
(OnboardingStep::ThemePicker, (5, 6)),
];
for (target, expected) in cases {
model.update(&mut app, |model, ctx| model.set_step(target, ctx));
let progress = model.read(&app, |model, _| model.progress());
assert_eq!(progress, expected, "unexpected dots for {target:?}");
}
});
}
#[test]
fn progress_reports_v3_positions_for_third_party_path() {
let _flag = FeatureFlag::OpenWarpNewSettingsModes.override_enabled(true);
App::test((), |mut app| async move {
let model = add_test_model(&mut app);
model.update(&mut app, |model, ctx| {
model.set_ai_setup_choice(AiSetupChoice::ThirdParty, ctx)
});
// Third-party path has no "Choose how to access AI" step, so it is one
// dot shorter than the Warp Agent path.
let cases = [
(OnboardingStep::Intention, (0, 5)),
(OnboardingStep::AiSetup, (1, 5)),
(OnboardingStep::ThirdParty, (2, 5)),
(OnboardingStep::Customize, (3, 5)),
(OnboardingStep::ThemePicker, (4, 5)),
];
for (target, expected) in cases {
model.update(&mut app, |model, ctx| model.set_step(target, ctx));
let progress = model.read(&app, |model, _| model.progress());
assert_eq!(progress, expected, "unexpected dots for {target:?}");
}
});
}
#[test]
fn progress_reports_terminal_path_uses_three_dot_variant() {
let _flag = FeatureFlag::OpenWarpNewSettingsModes.override_enabled(true);
App::test((), |mut app| async move {
let model = add_test_model(&mut app);
model.update(&mut app, |model, ctx| model.set_intention_terminal(ctx));
let cases = [
(OnboardingStep::Intention, (0, 3)),
(OnboardingStep::Customize, (1, 3)),
(OnboardingStep::ThemePicker, (2, 3)),
];
for (target, expected) in cases {
model.update(&mut app, |model, ctx| model.set_step(target, ctx));
let progress = model.read(&app, |model, _| model.progress());
assert_eq!(progress, expected, "unexpected dots for {target:?}");
}
});
}
+86 -633
View File
@@ -1,74 +1,33 @@
use super::two_line_button::{render_two_line_button, TwoLineButtonSpec};
use crate::model::{OnboardingAuthState, OnboardingStateEvent, OnboardingStateModel};
use crate::slides::{bottom_nav, layout, slide_content};
use crate::telemetry::OnboardingEvent;
use galaxy_core::send_telemetry_from_ctx;
use super::OnboardingSlide;
use crate::visuals::agent_visual;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::{
appearance::Appearance,
theme::{color::internal_colors, Fill},
};
use galaxyui::{
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,
},
fonts::Properties,
fonts::Weight,
keymap::Keystroke,
platform::Cursor,
scene::DropShadow,
text_layout::TextAlignment,
ui_components::components::{UiComponent as _, UiComponentStyles},
AppContext, Element, Entity, Gradient, SingletonEntity as _, TypedActionView, View,
ViewContext,
};
use pathfinder_geometry::vector::vec2f;
use ui_components::{button, Component as _, Options as _};
use ai::LLMId;
use galaxy_core::ui::icons::Icon;
use pathfinder_color::ColorU;
use ui_components::button::State as ButtonState;
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,
};
/// high-contrast "inverted" fill (foreground color)
struct UpgradeButtonTheme;
impl button::Theme for UpgradeButtonTheme {
fn background(
&self,
button_state: ButtonState,
appearance: &Appearance,
) -> Option<galaxy_core::ui::theme::Fill> {
use galaxy_core::ui::color::blend::Blend;
let theme = appearance.theme();
let base = theme.foreground();
match button_state {
ButtonState::Default => Some(base),
// Blend a little of the theme background back in to dim on hover /
// press. Opacities are relative to the foreground fill.
ButtonState::Hovered => Some(base.blend(&theme.background().with_opacity(15))),
ButtonState::Pressed => Some(base.blend(&theme.background().with_opacity(30))),
}
}
fn text_color(
&self,
background: Option<galaxy_core::ui::theme::Fill>,
appearance: &Appearance,
) -> ColorU {
let bg = background
.unwrap_or_else(|| appearance.theme().background())
.into_solid();
appearance.theme().font_color(bg).into()
}
}
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)]
@@ -76,7 +35,6 @@ pub struct OnboardingModelInfo {
pub id: LLMId,
pub title: String,
pub icon: Icon,
pub requires_upgrade: bool,
pub is_default: bool,
}
@@ -139,20 +97,10 @@ pub enum AgentSlideAction {
ToggleDisableOz,
BackClicked,
NextClicked,
UpgradeClicked,
CopyUpgradeUrlClicked,
PasteAuthTokenFromClipboardClicked,
DismissPlanActivatedToast,
}
#[derive(Debug, Clone)]
pub enum AgentSlideEvent {
CopyUpgradeUrlRequested,
PasteAuthTokenFromClipboardRequested,
}
pub struct AgentSlide {
onboarding_state: galaxyui::ModelHandle<OnboardingStateModel>,
onboarding_state: galaxyui_core::ModelHandle<OnboardingStateModel>,
/// Mouse state handles for each model row.
model_mouse_states: Vec<MouseStateHandle>,
@@ -164,25 +112,14 @@ pub struct AgentSlide {
autonomy_partial_mouse_state: MouseStateHandle,
autonomy_none_mouse_state: MouseStateHandle,
disable_oz_mouse: MouseStateHandle,
back_button: button::Button,
next_button: button::Button,
upgrade_button: button::Button,
scroll_state: ClippedScrollStateHandle,
dropdown_scroll_state: ClippedScrollStateHandle,
is_model_list_expanded: bool,
highlighted_model_id: Option<LLMId>,
show_auth_prompt_bar: bool,
copy_url_mouse_state: MouseStateHandle,
paste_token_mouse_state: MouseStateHandle,
show_plan_activated_toast: bool,
last_auth_state: OnboardingAuthState,
plan_activated_close_mouse_state: MouseStateHandle,
}
const PLAN_ACTIVATED_TOAST_DURATION: std::time::Duration = std::time::Duration::from_secs(5);
/// 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.
@@ -190,18 +127,9 @@ fn model_row_position_id(index: usize) -> String {
format!("agent_slide_model_row_{index}")
}
/// Returns the slide's view of the model list: free-tier before premium,
/// with server order preserved within each tier. The slide owns this sort so
/// state storage can stay in server order.
fn sorted_models(models: &[OnboardingModelInfo]) -> Vec<OnboardingModelInfo> {
let (free, premium): (Vec<_>, Vec<_>) =
models.iter().cloned().partition(|m| !m.requires_upgrade);
free.into_iter().chain(premium).collect()
}
impl AgentSlide {
pub(crate) fn new(
onboarding_state: galaxyui::ModelHandle<OnboardingStateModel>,
onboarding_state: galaxyui_core::ModelHandle<OnboardingStateModel>,
ctx: &mut ViewContext<Self>,
) -> Self {
let model_count = onboarding_state.as_ref(ctx).models().len();
@@ -209,8 +137,6 @@ impl AgentSlide {
.map(|_| MouseStateHandle::default())
.collect();
let initial_auth_state = onboarding_state.as_ref(ctx).auth_state();
ctx.subscribe_to_model(&onboarding_state, |me, model, event, ctx| {
match event {
OnboardingStateEvent::ModelsUpdated => {
@@ -226,30 +152,12 @@ impl AgentSlide {
let model_count = state.models().len();
me.ensure_mouse_states_for_models(model_count, ctx);
}
OnboardingStateEvent::AuthStateChanged => {
let new_state = model.as_ref(ctx).auth_state();
if new_state == OnboardingAuthState::PayingUser
&& me.last_auth_state != OnboardingAuthState::PayingUser
{
me.show_plan_activated_toast = true;
// Auto-dismiss after the configured duration.
let _ = ctx.spawn(
galaxyui::r#async::Timer::after(PLAN_ACTIVATED_TOAST_DURATION),
|me: &mut Self, _, ctx| {
if me.show_plan_activated_toast {
me.show_plan_activated_toast = false;
ctx.notify();
}
},
);
}
me.last_auth_state = new_state;
ctx.notify();
}
OnboardingStateEvent::SelectedSlideChanged
OnboardingStateEvent::AuthStateChanged
| OnboardingStateEvent::SelectedSlideChanged
| OnboardingStateEvent::IntentionChanged
| OnboardingStateEvent::Completed
| OnboardingStateEvent::UpgradeRequested => {}
| OnboardingStateEvent::UpgradeRequested
| OnboardingStateEvent::NoAiConfirmationChanged => {}
}
});
@@ -260,20 +168,12 @@ impl AgentSlide {
autonomy_full_mouse_state: MouseStateHandle::default(),
autonomy_partial_mouse_state: MouseStateHandle::default(),
autonomy_none_mouse_state: MouseStateHandle::default(),
disable_oz_mouse: MouseStateHandle::default(),
back_button: button::Button::default(),
next_button: button::Button::default(),
upgrade_button: button::Button::default(),
scroll_state: ClippedScrollStateHandle::new(),
dropdown_scroll_state: ClippedScrollStateHandle::new(),
is_model_list_expanded: false,
highlighted_model_id: None,
show_auth_prompt_bar: false,
copy_url_mouse_state: MouseStateHandle::default(),
paste_token_mouse_state: MouseStateHandle::default(),
show_plan_activated_toast: false,
last_auth_state: initial_auth_state,
plan_activated_close_mouse_state: MouseStateHandle::default(),
}
}
@@ -308,7 +208,7 @@ impl AgentSlide {
// 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);
let bottom_nav = self.render_bottom_nav(appearance, app);
slide_content::onboarding_slide_content(
vec![
self.render_header(appearance),
@@ -333,7 +233,7 @@ impl AgentSlide {
.finish();
let subtitle = FormattedTextElement::from_str(
"Select your in-app agent's defaults.",
"Select your Warp Agent's defaults.",
appearance.ui_font_family(),
16.,
)
@@ -389,19 +289,10 @@ impl AgentSlide {
upper_col.finish()
};
let mut col = Flex::column()
let col = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(upper_sections);
if FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
let disable_oz_section = self.render_disable_oz_section(appearance, settings);
col = col.with_child(
Container::new(disable_oz_section)
.with_margin_top(24.)
.finish(),
);
}
Container::new(col.finish()).with_margin_top(40.).finish()
}
@@ -458,31 +349,15 @@ impl AgentSlide {
);
}
let has_disabled = self
.onboarding_state
.as_ref(app)
.models()
.iter()
.any(|m| m.requires_upgrade);
let mut col = Flex::column()
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(header)
.with_child(
Container::new(chip_stack.finish())
.with_margin_top(12.)
.finish(),
);
if has_disabled {
col = col.with_child(
Container::new(self.render_upgrade_banner(appearance))
.with_margin_top(12.)
.finish(),
);
}
col.finish()
)
.finish()
}
/// Renders the single-row collapsed picker button: provider icon, selected title,
@@ -595,9 +470,8 @@ impl AgentSlide {
}
/// Renders the vertical list of model rows shown inside the floating dropdown
/// overlay. Each row: provider icon + title on the left, pill on the right
/// (Premium for paywalled rows). Disabled rows are rendered dimmed and are
/// not clickable or hover-selectable.
/// 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,
@@ -609,7 +483,7 @@ impl AgentSlide {
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 = sorted_models(state.models());
let models = state.models();
let mut col = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
@@ -695,18 +569,11 @@ impl AgentSlide {
let background_for_text = theme.background().into_solid();
let ui_font_family = appearance.ui_font_family();
let is_disabled = model.requires_upgrade;
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 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 requires_upgrade = model.requires_upgrade;
let is_default = model.is_default;
let hoverable_body = Hoverable::new(mouse_state, move |_| {
@@ -730,10 +597,7 @@ impl AgentSlide {
.with_child(Container::new(title_el).with_margin_left(8.).finish())
.finish();
// Trailing pills: "Recommended" on the server-designated default
// model, "Premium" on paywalled rows. In practice a single row is
// at most one of these, but both can be shown side-by-side if the
// default is also premium for any reason.
// "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))
@@ -755,8 +619,6 @@ impl AgentSlide {
let trailing: Box<dyn Element> = if is_default {
make_pill("Recommended")
} else if requires_upgrade {
make_pill("Premium")
} else {
Empty::new().finish()
};
@@ -769,7 +631,7 @@ impl AgentSlide {
.with_child(trailing)
.finish();
let background = if is_highlighted && !is_disabled {
let background = if is_highlighted {
Some(Fill::Solid(internal_colors::neutral_2(theme)))
} else {
None
@@ -787,26 +649,19 @@ impl AgentSlide {
.finish()
});
if is_disabled {
// Disabled rows: no click, no hover-updates-highlight, muted.
hoverable_body.finish()
} else {
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()
}
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> {
@@ -881,19 +736,19 @@ impl AgentSlide {
(
AgentAutonomy::Full,
"Full",
"Runs commands, writes code, and reads files without asking.",
"Warp Agent runs commands, writes code, and reads files without asking.",
self.autonomy_full_mouse_state.clone(),
),
(
AgentAutonomy::Partial,
"Partial",
"Can plan, read files, and execute low-risk commands. Asks before making any changes or executing sensitive commands.",
"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",
"Takes no actions without your approval.",
"Warp Agent takes no actions without your approval.",
self.autonomy_none_mouse_state.clone(),
),
];
@@ -939,39 +794,7 @@ impl AgentSlide {
.finish()
}
fn render_disable_oz_section(
&self,
appearance: &Appearance,
settings: &AgentDevelopmentSettings,
) -> Box<dyn Element> {
let theme = appearance.theme();
let background_for_text = theme.background().into_solid();
let checkbox = appearance
.ui_builder()
.checkbox(self.disable_oz_mouse.clone(), Some(12.))
.check(settings.disable_oz)
.build()
.on_click(|ctx, _, _| ctx.dispatch_typed_action(AgentSlideAction::ToggleDisableOz))
.finish();
let label = Text::new("Disable Warp Agent", appearance.ui_font_family(), 14.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();
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(checkbox)
.with_child(Container::new(label).with_margin_left(8.).finish())
.finish()
}
fn render_bottom_nav(&self, appearance: &Appearance) -> Box<dyn Element> {
fn render_bottom_nav(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let back_button = self.back_button.render(
appearance,
button::Params {
@@ -1002,13 +825,7 @@ impl AgentSlide {
},
);
let step_index = 2;
let step_count =
if galaxy_core::features::FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
5
} else {
4
};
let (step_index, step_count) = self.onboarding_state.as_ref(app).progress();
bottom_nav::onboarding_bottom_nav(
appearance,
step_index,
@@ -1018,98 +835,6 @@ impl AgentSlide {
)
}
fn render_upgrade_banner(&self, appearance: &Appearance) -> Box<dyn Element> {
// Diagonal magenta → yellow gradient (top-left to bottom-right). Chosen
// to match the "premium" glow styling in the Figma mocks.
const GRADIENT_START_MAGENTA: ColorU = ColorU {
r: 0xE2,
g: 0x48,
b: 0xBC,
a: 0xFF,
};
const GRADIENT_END_YELLOW: ColorU = ColorU {
r: 0xF5,
g: 0xB7,
b: 0x00,
a: 0xFF,
};
let theme = appearance.theme();
let background_for_text = theme.background().into_solid();
let ui_font_family = appearance.ui_font_family();
// Primary "heading" line: bolder, full-contrast.
let title = Text::new(
"Upgrade for access to premium models.",
ui_font_family,
13.0,
)
.with_color(internal_colors::text_main(theme, background_for_text))
.with_style(Properties {
weight: Weight::Medium,
..Default::default()
})
.with_line_height_ratio(1.2)
.finish();
// Secondary subtext: muted, normal weight.
let subtitle = Text::new(
"State-of-the-art models require paid plans.",
ui_font_family,
12.0,
)
.with_color(internal_colors::text_sub(theme, background_for_text))
.with_style(Properties {
weight: Weight::Normal,
..Default::default()
})
.with_line_height_ratio(1.2)
.finish();
let text_col = 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(4.).finish())
.finish();
let upgrade_button = self.upgrade_button.render(
appearance,
button::Params {
content: button::Content::Label("Upgrade".into()),
theme: &UpgradeButtonTheme,
options: button::Options {
size: button::Size::Small,
on_click: Some(Box::new(|ctx, _app, _pos| {
ctx.dispatch_typed_action(AgentSlideAction::UpgradeClicked);
})),
..button::Options::default(appearance)
},
},
);
let row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(text_col)
.with_child(upgrade_button)
.finish();
Container::new(row)
.with_uniform_padding(12.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_border(Border::all(1.).with_border_gradient(
vec2f(0., 0.),
vec2f(1., 1.),
Gradient {
start: GRADIENT_START_MAGENTA,
end: GRADIENT_END_YELLOW,
},
))
.finish()
}
fn render_visual(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let theme = appearance.theme();
@@ -1138,187 +863,10 @@ impl AgentSlide {
.finish()
}
}
/// Full-width bar pinned below the slide's two-column layout. Shown after
/// the user clicks the Upgrade button, so they can fall back to copying
/// the upgrade URL (or pasting the returned auth token) if the browser
/// didn't launch automatically.
fn render_auth_prompt_bar(&self, appearance: &Appearance) -> Box<dyn Element> {
const BAR_HEIGHT: f32 = 40.;
const ICON_SIZE: f32 = 14.;
const FONT_SIZE: f32 = 12.;
let theme = appearance.theme();
let bar_bg = theme.surface_1();
let bar_bg_solid = bar_bg.into_solid();
let text_color = internal_colors::text_sub(theme, bar_bg_solid);
let ui_builder = appearance.ui_builder();
let text_styles = UiComponentStyles {
font_color: Some(text_color),
font_size: Some(FONT_SIZE),
..Default::default()
};
let link_styles = UiComponentStyles {
font_size: Some(FONT_SIZE),
..Default::default()
};
let icon = ConstrainedBox::new(Box::new(
Icon::AlertCircle.to_galaxyui_icon(Fill::Solid(text_color)),
))
.with_width(ICON_SIZE)
.with_height(ICON_SIZE)
.finish();
let copy_url_link = ui_builder
.link(
"copy the URL".into(),
None,
Some(Box::new(|ctx| {
ctx.dispatch_typed_action(AgentSlideAction::CopyUpgradeUrlClicked);
})),
self.copy_url_mouse_state.clone(),
)
.soft_wrap(false)
.with_style(link_styles)
.build()
.finish();
let paste_token_link = ui_builder
.link(
"Click here".into(),
None,
Some(Box::new(|ctx| {
ctx.dispatch_typed_action(AgentSlideAction::PasteAuthTokenFromClipboardClicked);
})),
self.paste_token_mouse_state.clone(),
)
.soft_wrap(false)
.with_style(link_styles)
.build()
.finish();
let text_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(icon)
.with_child(
Container::new(
ui_builder
.span("If your browser hasn't launched, ")
.with_style(text_styles)
.build()
.finish(),
)
.with_margin_left(8.)
.finish(),
)
.with_child(copy_url_link)
.with_child(
ui_builder
.span(" and open the page manually. ")
.with_style(text_styles)
.build()
.finish(),
)
.with_child(paste_token_link)
.with_child(
ui_builder
.span(" to paste your token from the browser.")
.with_style(text_styles)
.build()
.finish(),
)
.finish();
let row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(text_row)
.finish();
ConstrainedBox::new(
Container::new(row)
.with_background(bar_bg)
.with_border(Border::top(1.).with_border_color(internal_colors::neutral_4(theme)))
.with_horizontal_padding(16.)
.finish(),
)
.with_min_height(BAR_HEIGHT)
.finish()
}
/// Green success pill shown when the user's `OnboardingAuthState`
/// transitions into `PayingUser`. Auto-dismisses after
/// `PLAN_ACTIVATED_TOAST_DURATION`; also dismissable via the close X.
fn render_plan_activated_toast(&self, appearance: &Appearance) -> Box<dyn Element> {
const TOAST_MIN_HEIGHT: f32 = 40.;
const ICON_SIZE: f32 = 14.;
const CLOSE_SIZE: f32 = 16.;
const FONT_SIZE: f32 = 12.;
let theme = appearance.theme();
let toast_bg: Fill = theme.ansi_fg_green().into();
let text_color: ColorU = theme.font_color(toast_bg.into_solid()).into();
let ui_builder = appearance.ui_builder();
let check_icon = ConstrainedBox::new(Box::new(
Icon::CheckSkinny.to_galaxyui_icon(Fill::Solid(text_color)),
))
.with_width(ICON_SIZE)
.with_height(ICON_SIZE)
.finish();
let text = ui_builder
.span("Plan successfully activated. All premium models are available.")
.with_style(UiComponentStyles {
font_color: Some(text_color),
font_size: Some(FONT_SIZE),
font_weight: Some(Weight::Medium),
..Default::default()
})
.build()
.finish();
let close_button = ui_builder
.close_button(CLOSE_SIZE, self.plan_activated_close_mouse_state.clone())
.with_style(UiComponentStyles {
font_color: Some(text_color),
..Default::default()
})
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(AgentSlideAction::DismissPlanActivatedToast);
})
.finish();
let left = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(check_icon)
.with_child(Container::new(text).with_margin_left(8.).finish())
.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(left)
.with_child(close_button)
.finish();
ConstrainedBox::new(
Container::new(row)
.with_background(toast_bg)
.with_horizontal_padding(16.)
.finish(),
)
.with_min_height(TOAST_MIN_HEIGHT)
.finish()
}
}
impl Entity for AgentSlide {
type Event = AgentSlideEvent;
type Event = ();
}
impl View for AgentSlide {
@@ -1334,39 +882,10 @@ impl View for AgentSlide {
// 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.
let slide = layout::static_left(
layout::static_left(
|| self.render_content(appearance, settings, workspace_enforces_autonomy, app),
|| self.render_visual(appearance, app),
);
// Upgrade-prompt bar: shown after the user clicks Upgrade, as long as
// they aren't yet on a paid plan. Overlays the bottom of the slide
// (doesn't bump slide content up) so the slide layout stays stable
// whether or not the bar is visible.
//
// The plan-activated success toast supersedes the bar (and any other
// bottom overlay) while it's visible.
let auth_state = self.onboarding_state.as_ref(app).auth_state();
let show_bar =
self.show_auth_prompt_bar && !matches!(auth_state, OnboardingAuthState::PayingUser);
if !show_bar && !self.show_plan_activated_toast {
return slide;
}
let bottom_overlay = if self.show_plan_activated_toast {
self.render_plan_activated_toast(appearance)
} else {
self.render_auth_prompt_bar(appearance)
};
let mut stack = Stack::new();
stack.add_child(slide);
stack.add_child(
galaxyui::elements::Align::new(bottom_overlay)
.bottom_center()
.finish(),
);
stack.finish()
)
}
}
@@ -1388,10 +907,7 @@ impl AgentSlide {
// 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) = sorted_models(state.models())
.iter()
.position(|m| m.id == selected_id)
{
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,
@@ -1402,55 +918,33 @@ impl AgentSlide {
ctx.notify();
}
/// Finds the next enabled model index in the given direction, wrapping
/// around. Indices are into the slide's sorted view of the model list.
/// Returns `None` if all models are paywalled.
fn next_enabled_model_index(
&self,
start: usize,
forward: bool,
ctx: &AppContext,
) -> Option<usize> {
let models = sorted_models(self.onboarding_state.as_ref(ctx).models());
let count = models.len();
if count == 0 {
return None;
}
for offset in 1..=count {
let idx = if forward {
(start + offset) % count
} else {
(start + count - offset) % count
};
if !models[idx].requires_upgrade {
return Some(idx);
}
}
None
}
/// Advances the highlight cursor to the next/previous enabled model, wrapping.
/// The origin of the walk is the currently-highlighted id (if any), else the
/// 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 state = self.onboarding_state.as_ref(ctx);
let sorted = sorted_models(state.models());
let selected_id = state.agent_settings().selected_model_id.clone();
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| sorted.iter().position(|m| &m.id == id))
.or_else(|| sorted.iter().position(|m| m.id == selected_id))
.and_then(|id| model_ids.iter().position(|m| m == id))
.or_else(|| model_ids.iter().position(|m| *m == selected_id))
.unwrap_or(0);
let Some(next_index) = self.next_enabled_model_index(start_index, forward, ctx) else {
return;
let next_index = if forward {
(start_index + 1) % count
} else {
(start_index + count - 1) % count
};
let Some(next_id) = sorted.get(next_index).map(|m| m.id.clone()) else {
return;
};
self.highlighted_model_id = Some(next_id);
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.
@@ -1532,16 +1026,7 @@ impl OnboardingSlide for AgentSlide {
// 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() {
// Only select if the highlighted row is still enabled.
let enabled = self
.onboarding_state
.as_ref(ctx)
.models()
.iter()
.any(|m| m.id == id && !m.requires_upgrade);
if enabled {
self.select_model(id, ctx);
}
self.select_model(id, ctx);
}
self.set_model_list_expanded(false, ctx);
return;
@@ -1578,16 +1063,7 @@ impl TypedActionView for AgentSlide {
self.set_model_list_expanded(!self.is_model_list_expanded, ctx);
}
AgentSlideAction::HighlightModel(model_id) => {
// Only update if the id corresponds to an enabled row. Callers
// (hover handlers) already filter this out, but we defend against
// stale actions fired while the list was re-rendering.
let enabled = self
.onboarding_state
.as_ref(ctx)
.models()
.iter()
.any(|m| m.id == *model_id && !m.requires_upgrade);
if enabled && self.highlighted_model_id.as_ref() != Some(model_id) {
if self.highlighted_model_id.as_ref() != Some(model_id) {
self.highlighted_model_id = Some(model_id.clone());
ctx.notify();
}
@@ -1616,29 +1092,6 @@ impl TypedActionView for AgentSlide {
AgentSlideAction::NextClicked => {
self.next(ctx);
}
AgentSlideAction::UpgradeClicked => {
send_telemetry_from_ctx!(OnboardingEvent::AgentSlideUpgradeClicked, ctx);
if !matches!(
self.onboarding_state.as_ref(ctx).auth_state(),
OnboardingAuthState::PayingUser,
) {
self.show_auth_prompt_bar = true;
ctx.notify();
}
self.onboarding_state.update(ctx, |state, ctx| {
state.request_upgrade(ctx);
});
}
AgentSlideAction::CopyUpgradeUrlClicked => {
ctx.emit(AgentSlideEvent::CopyUpgradeUrlRequested);
}
AgentSlideAction::PasteAuthTokenFromClipboardClicked => {
ctx.emit(AgentSlideEvent::PasteAuthTokenFromClipboardRequested);
}
AgentSlideAction::DismissPlanActivatedToast => {
self.show_plan_activated_toast = false;
ctx.notify();
}
}
}
}
@@ -0,0 +1,622 @@
use ui_components::{button, Component as _, Options as _};
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::icons::Icon;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::Fill;
use galaxyui_core::elements::{
Border, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Flex, FormattedTextElement, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle,
ParentElement, Radius, Stack,
};
use galaxyui_core::fonts::Weight;
use galaxyui_core::keymap::Keystroke;
use galaxyui_core::platform::Cursor;
use galaxyui_core::prelude::Align;
use galaxyui_core::text_layout::TextAlignment;
use galaxyui_core::ui_components::components::{UiComponent as _, UiComponentStyles};
use galaxyui_core::{
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
ViewContext,
};
use super::OnboardingSlide;
use crate::model::{AiAccessChoice, OnboardingAuthState, OnboardingStateModel};
use crate::slides::{bottom_nav, layout, slide_content};
#[derive(Debug, Clone)]
pub enum AiAccessSlideAction {
SelectSubscription,
SelectSetUpLater,
CopyUpgradeUrlClicked,
PasteAuthTokenFromClipboardClicked,
BackClicked,
NextClicked,
}
/// Emitted to the parent onboarding view so the (app-crate) upgrade fallback
/// actions can be handled at the root level — the onboarding crate can't
/// reference them directly.
#[derive(Debug, Clone)]
pub enum AiAccessSlideEvent {
CopyUpgradeUrlRequested,
PasteAuthTokenFromClipboardRequested,
}
/// The "Choose how to access AI" slide (Warp Agent path). Forks between a paid
/// subscription and a "Set up later" option that lets the user explore Warp's
/// built-in AI before committing to a plan.
pub struct AiAccessSlide {
onboarding_state: ModelHandle<OnboardingStateModel>,
subscription_mouse_state: MouseStateHandle,
set_up_later_mouse_state: MouseStateHandle,
back_button: button::Button,
next_button: button::Button,
scroll_state: ClippedScrollStateHandle,
show_auth_prompt_bar: bool,
copy_url_mouse_state: MouseStateHandle,
paste_token_mouse_state: MouseStateHandle,
}
impl AiAccessSlide {
pub(crate) fn new(onboarding_state: ModelHandle<OnboardingStateModel>) -> Self {
Self {
onboarding_state,
subscription_mouse_state: MouseStateHandle::default(),
set_up_later_mouse_state: MouseStateHandle::default(),
back_button: button::Button::default(),
next_button: button::Button::default(),
scroll_state: ClippedScrollStateHandle::new(),
show_auth_prompt_bar: false,
copy_url_mouse_state: MouseStateHandle::default(),
paste_token_mouse_state: MouseStateHandle::default(),
}
}
// The final DES-816 visual exports have not landed yet, so the right panel
// reuses the existing bundled agent welcome image.
pub(crate) const VISUAL_IMAGE_PATHS: &'static [&'static str] =
&["async/png/onboarding/welcome_agent.png"];
fn choice(&self, app: &AppContext) -> AiAccessChoice {
self.onboarding_state.as_ref(app).ai_access_choice()
}
fn render_content(
&self,
appearance: &Appearance,
choice: AiAccessChoice,
app: &AppContext,
) -> Box<dyn Element> {
let bottom_nav = Align::new(self.render_bottom_nav(appearance, app)).finish();
slide_content::onboarding_slide_content(
vec![
Align::new(self.render_header(appearance)).left().finish(),
Align::new(self.render_options(appearance, choice)).finish(),
],
bottom_nav,
self.scroll_state.clone(),
appearance,
)
}
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let title = appearance
.ui_builder()
.paragraph("Get AI access")
.with_style(UiComponentStyles {
font_size: Some(36.),
font_weight: Some(Weight::Medium),
..Default::default()
})
.build()
.finish();
let subtitle = FormattedTextElement::from_str(
"Save with a recurring plan, or explore Warp's AI before committing.",
appearance.ui_font_family(),
16.,
)
.with_color(internal_colors::text_sub(
theme,
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_options(&self, appearance: &Appearance, choice: AiAccessChoice) -> Box<dyn Element> {
let subscription_card = self
.render_subscription_card(appearance, matches!(choice, AiAccessChoice::Subscription));
let set_up_later_card =
self.render_set_up_later_card(appearance, matches!(choice, AiAccessChoice::SetUpLater));
Container::new(
Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(
Container::new(subscription_card)
.with_margin_bottom(12.)
.finish(),
)
.with_child(set_up_later_card)
.finish(),
)
.with_margin_top(38.)
.finish()
}
/// Shared chrome for an option card: selected/unselected background + border,
/// hover/click to select.
fn render_card_chrome(
appearance: &Appearance,
is_selected: bool,
mouse_state: MouseStateHandle,
select_action: AiAccessSlideAction,
content: Box<dyn Element>,
) -> Box<dyn Element> {
const RADIUS: f32 = 8.;
let theme = appearance.theme();
let background = if is_selected {
Some(internal_colors::accent_overlay_1(theme))
} else {
None
};
let border_color = if is_selected {
theme.accent()
} else {
Fill::Solid(internal_colors::neutral_4(theme))
};
Hoverable::new(mouse_state, move |_| {
let mut container = Container::new(content)
.with_uniform_padding(24.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(RADIUS)))
.with_border(Border::all(1.).with_border_fill(border_color));
if let Some(bg) = background {
container = container.with_background(bg);
}
container.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(select_action.clone());
})
.finish()
}
fn render_subscription_card(
&self,
appearance: &Appearance,
is_selected: bool,
) -> Box<dyn Element> {
let theme = appearance.theme();
let bg_solid = theme.background().into_solid();
let label_color = if is_selected {
internal_colors::text_main(theme, bg_solid)
} else {
internal_colors::text_sub(theme, bg_solid)
};
let description_color = internal_colors::text_sub(theme, bg_solid);
let label = appearance
.ui_builder()
.paragraph("Subscription")
.with_style(UiComponentStyles {
font_size: Some(16.),
font_weight: Some(Weight::Semibold),
font_color: Some(label_color),
..Default::default()
})
.build()
.finish();
let badge = {
let green = theme.ansi_fg_green();
let badge_text = appearance
.ui_builder()
.paragraph("Best value")
.with_style(UiComponentStyles {
font_size: Some(12.),
font_weight: Some(Weight::Normal),
font_color: Some(green),
..Default::default()
})
.build()
.finish();
Container::new(badge_text)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(11.)))
.with_border(Border::all(1.).with_border_fill(Fill::Solid(green)))
.with_horizontal_padding(8.)
.with_vertical_padding(3.)
.finish()
};
let header_row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(label)
.with_child(badge)
.finish();
let description = FormattedTextElement::from_str(
"Starting at $18 / mo, available with monthly or annual plans. Includes base credits, \
frontier models, cloud agents, collaboration, and more.",
appearance.ui_font_family(),
14.,
)
.with_color(description_color)
.with_weight(Weight::Normal)
.with_alignment(TextAlignment::Left)
.with_line_height_ratio(1.2)
.finish();
let content = Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(header_row)
.with_child(Container::new(description).with_margin_top(12.).finish())
.finish();
Self::render_card_chrome(
appearance,
is_selected,
self.subscription_mouse_state.clone(),
AiAccessSlideAction::SelectSubscription,
content,
)
}
fn render_set_up_later_card(
&self,
appearance: &Appearance,
is_selected: bool,
) -> Box<dyn Element> {
let theme = appearance.theme();
let bg_solid = theme.background().into_solid();
let label_color = if is_selected {
internal_colors::text_main(theme, bg_solid)
} else {
internal_colors::text_sub(theme, bg_solid)
};
let description_color = internal_colors::text_sub(theme, bg_solid);
let label = appearance
.ui_builder()
.paragraph("Set up later")
.with_style(UiComponentStyles {
font_size: Some(16.),
font_weight: Some(Weight::Semibold),
font_color: Some(label_color),
..Default::default()
})
.build()
.finish();
let description = FormattedTextElement::from_str(
"Explore Warp's built-in AI features before committing to a plan, or bring your own \
inference.",
appearance.ui_font_family(),
14.,
)
.with_color(description_color)
.with_weight(Weight::Normal)
.with_alignment(TextAlignment::Left)
.with_line_height_ratio(1.2)
.finish();
let content = Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(label)
.with_child(Container::new(description).with_margin_top(12.).finish())
.finish();
Self::render_card_chrome(
appearance,
is_selected,
self.set_up_later_mouse_state.clone(),
AiAccessSlideAction::SelectSetUpLater,
content,
)
}
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(AiAccessSlideAction::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(AiAccessSlideAction::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) -> Box<dyn Element> {
layout::onboarding_right_panel_with_bg(
Self::VISUAL_IMAGE_PATHS[0],
layout::FOREGROUND_LAYOUT_DEFAULT,
)
}
/// Full-width bar pinned below the slide's two-column layout. Shown after
/// the user picks Subscription and clicks "Next", so they can fall back to
/// copying the upgrade URL (or pasting the returned auth token) if the
/// browser didn't launch automatically.
fn render_auth_prompt_bar(&self, appearance: &Appearance) -> Box<dyn Element> {
const BAR_HEIGHT: f32 = 40.;
const ICON_SIZE: f32 = 14.;
const FONT_SIZE: f32 = 12.;
let theme = appearance.theme();
let bar_bg = theme.surface_1();
let bar_bg_solid = bar_bg.into_solid();
let text_color = internal_colors::text_sub(theme, bar_bg_solid);
let ui_builder = appearance.ui_builder();
let text_styles = UiComponentStyles {
font_color: Some(text_color),
font_size: Some(FONT_SIZE),
..Default::default()
};
let link_styles = UiComponentStyles {
font_size: Some(FONT_SIZE),
..Default::default()
};
let icon = ConstrainedBox::new(Box::new(
Icon::AlertCircle.to_warpui_icon(Fill::Solid(text_color)),
))
.with_width(ICON_SIZE)
.with_height(ICON_SIZE)
.finish();
let copy_url_link = ui_builder
.link(
"copy the URL".into(),
None,
Some(Box::new(|ctx| {
ctx.dispatch_typed_action(AiAccessSlideAction::CopyUpgradeUrlClicked);
})),
self.copy_url_mouse_state.clone(),
)
.soft_wrap(false)
.with_style(link_styles)
.build()
.finish();
let paste_token_link = ui_builder
.link(
"Click here".into(),
None,
Some(Box::new(|ctx| {
ctx.dispatch_typed_action(
AiAccessSlideAction::PasteAuthTokenFromClipboardClicked,
);
})),
self.paste_token_mouse_state.clone(),
)
.soft_wrap(false)
.with_style(link_styles)
.build()
.finish();
let text_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(icon)
.with_child(
Container::new(
ui_builder
.span("If your browser hasn't launched, ")
.with_style(text_styles)
.build()
.finish(),
)
.with_margin_left(8.)
.finish(),
)
.with_child(copy_url_link)
.with_child(
ui_builder
.span(" and open the page manually. ")
.with_style(text_styles)
.build()
.finish(),
)
.with_child(paste_token_link)
.with_child(
ui_builder
.span(" to paste your token from the browser.")
.with_style(text_styles)
.build()
.finish(),
)
.finish();
let row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(text_row)
.finish();
ConstrainedBox::new(
Container::new(row)
.with_background(bar_bg)
.with_border(Border::top(1.).with_border_color(internal_colors::neutral_4(theme)))
.with_horizontal_padding(16.)
.finish(),
)
.with_min_height(BAR_HEIGHT)
.finish()
}
}
impl Entity for AiAccessSlide {
type Event = AiAccessSlideEvent;
}
impl View for AiAccessSlide {
fn ui_name() -> &'static str {
"AiAccessSlide"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let choice = self.choice(app);
let slide = layout::static_left(
|| self.render_content(appearance, choice, app),
|| self.render_visual(),
);
// Overlay the fallback bar at the bottom (rather than adding it to the
// column) so the slide layout stays stable whether or not it's shown.
let show_bar = self.show_auth_prompt_bar
&& !matches!(
self.onboarding_state.as_ref(app).auth_state(),
OnboardingAuthState::PayingUser,
);
if !show_bar {
return slide;
}
let mut stack = Stack::new();
stack.add_child(slide);
stack.add_child(
Align::new(self.render_auth_prompt_bar(appearance))
.bottom_center()
.finish(),
);
stack.finish()
}
}
impl AiAccessSlide {
fn select_choice(&mut self, choice: AiAccessChoice, ctx: &mut ViewContext<Self>) {
self.onboarding_state.update(ctx, |model, ctx| {
model.set_ai_access_choice(choice, ctx);
});
ctx.notify();
}
fn next(&mut self, ctx: &mut ViewContext<Self>) {
self.onboarding_state.update(ctx, |model, ctx| {
model.next(ctx);
});
}
/// Primary "Next" action. On the subscription path this advances when the
/// user already has a plan, otherwise it launches checkout in the browser
/// (the slide auto-advances once billing flips to a paying plan). The "Set
/// up later" path always advances.
fn advance_or_upgrade(&mut self, ctx: &mut ViewContext<Self>) {
match self.choice(ctx) {
AiAccessChoice::Subscription => {
if matches!(
self.onboarding_state.as_ref(ctx).auth_state(),
OnboardingAuthState::PayingUser
) {
self.next(ctx);
} else {
// Surface the manual-fallback bar in case the browser
// doesn't launch; it's hidden again once billing flips to
// PayingUser.
self.show_auth_prompt_bar = true;
self.onboarding_state.update(ctx, |model, ctx| {
model.request_upgrade(ctx);
});
ctx.notify();
}
}
AiAccessChoice::SetUpLater => self.next(ctx),
}
}
}
impl OnboardingSlide for AiAccessSlide {
fn on_up(&mut self, ctx: &mut ViewContext<Self>) {
self.select_choice(AiAccessChoice::Subscription, ctx);
}
fn on_down(&mut self, ctx: &mut ViewContext<Self>) {
self.select_choice(AiAccessChoice::SetUpLater, ctx);
}
fn on_enter(&mut self, ctx: &mut ViewContext<Self>) {
self.advance_or_upgrade(ctx);
}
}
impl TypedActionView for AiAccessSlide {
type Action = AiAccessSlideAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
AiAccessSlideAction::SelectSubscription => {
self.select_choice(AiAccessChoice::Subscription, ctx);
}
AiAccessSlideAction::SelectSetUpLater => {
self.select_choice(AiAccessChoice::SetUpLater, ctx);
}
AiAccessSlideAction::CopyUpgradeUrlClicked => {
ctx.emit(AiAccessSlideEvent::CopyUpgradeUrlRequested);
}
AiAccessSlideAction::PasteAuthTokenFromClipboardClicked => {
ctx.emit(AiAccessSlideEvent::PasteAuthTokenFromClipboardRequested);
}
AiAccessSlideAction::BackClicked => {
self.onboarding_state.update(ctx, |model, ctx| {
model.back(ctx);
});
}
AiAccessSlideAction::NextClicked => {
self.advance_or_upgrade(ctx);
}
}
}
}
@@ -0,0 +1,520 @@
use ui_components::{button, Component as _, Options as _};
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::Fill;
use galaxy_core::ui::Icon;
use galaxyui_core::elements::{
Border, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Flex, FormattedTextElement, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle,
ParentElement, Radius,
};
use galaxyui_core::fonts::Weight;
use galaxyui_core::keymap::Keystroke;
use galaxyui_core::platform::Cursor;
use galaxyui_core::prelude::Align;
use galaxyui_core::text_layout::TextAlignment;
use galaxyui_core::ui_components::components::{UiComponent as _, UiComponentStyles};
use galaxyui_core::{
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
ViewContext,
};
use super::OnboardingSlide;
use crate::model::{AiSetupChoice, OnboardingStateModel};
use crate::slides::{bottom_nav, layout, slide_content};
/// Checklist shown on the "Use Warp agent" card.
const WARP_AGENT_FEATURES: &[&str] = &[
"Best harness for terminal tasks and agentic coding",
"Frontier models from OpenAI, Anthropic, and Google",
"Model routing across frontier and open-weight models",
"Multi-agent orchestration",
];
#[derive(Debug, Clone)]
pub enum AiSetupSlideAction {
SelectWarpAgent,
SelectThirdParty,
BackClicked,
NextClicked,
}
/// The "Choose your AI setup" slide (DES-816 V3), shown on the AI-first path for
/// users enrolled in the FREE_AI_REMOVAL experiment arm. Forks between the Warp
/// agent (paid-plan path) and third-party agents (works on Free).
pub struct AiSetupSlide {
onboarding_state: ModelHandle<OnboardingStateModel>,
warp_agent_mouse_state: MouseStateHandle,
third_party_mouse_state: MouseStateHandle,
back_button: button::Button,
next_button: button::Button,
scroll_state: ClippedScrollStateHandle,
}
impl AiSetupSlide {
pub(crate) fn new(onboarding_state: ModelHandle<OnboardingStateModel>) -> Self {
Self {
onboarding_state,
warp_agent_mouse_state: MouseStateHandle::default(),
third_party_mouse_state: MouseStateHandle::default(),
back_button: button::Button::default(),
next_button: button::Button::default(),
scroll_state: ClippedScrollStateHandle::new(),
}
}
// The final DES-816 visual exports have not landed yet, so the right panel
// reuses existing bundled assets that match each choice: the agent
// experience for "Use Warp agent" and the CLI-agent toolbar for
// "Use third party agents".
pub(crate) const VISUAL_IMAGE_PATHS: &'static [&'static str] = &[
"async/png/onboarding/welcome_agent.png",
"async/png/onboarding/thirdparty_toolbar_enabled_vertical.png",
];
fn choice(&self, app: &AppContext) -> AiSetupChoice {
self.onboarding_state.as_ref(app).ai_setup_choice()
}
fn render_content(
&self,
appearance: &Appearance,
choice: AiSetupChoice,
app: &AppContext,
) -> Box<dyn Element> {
let bottom_nav = Align::new(self.render_bottom_nav(appearance, app)).finish();
slide_content::onboarding_slide_content(
vec![
Align::new(self.render_header(appearance)).left().finish(),
Align::new(self.render_options(appearance, choice)).finish(),
],
bottom_nav,
self.scroll_state.clone(),
appearance,
)
}
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let logo_fill = internal_colors::fg_overlay_4(theme);
let logo = ConstrainedBox::new(Icon::WarpLogoLight.to_warpui_icon(logo_fill).finish())
.with_width(64.)
.with_height(64.)
.finish();
let title = appearance
.ui_builder()
.paragraph("Choose your AI setup")
.with_style(UiComponentStyles {
font_size: Some(36.),
font_weight: Some(Weight::Medium),
..Default::default()
})
.build()
.finish();
let subtitle = FormattedTextElement::from_str(
"Choose if you'd like to use Warp Agent or third party agents.",
appearance.ui_font_family(),
16.,
)
.with_color(internal_colors::text_sub(
theme,
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)
// Offset icon built in padding to left align icon with title.
.with_child(Container::new(logo).with_margin_left(-7.).finish())
.with_child(Container::new(title).with_margin_top(11.).finish())
.with_child(Container::new(subtitle).with_margin_top(16.).finish())
.finish()
}
fn render_options(&self, appearance: &Appearance, choice: AiSetupChoice) -> Box<dyn Element> {
let warp_agent_card = self.render_warp_agent_card(
appearance,
matches!(choice, AiSetupChoice::WarpAgent),
self.warp_agent_mouse_state.clone(),
);
let third_party_card = self.render_third_party_card(
appearance,
matches!(choice, AiSetupChoice::ThirdParty),
self.third_party_mouse_state.clone(),
);
Container::new(
Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(
Container::new(warp_agent_card)
.with_margin_bottom(12.)
.finish(),
)
.with_child(third_party_card)
.finish(),
)
.with_margin_top(38.)
.finish()
}
/// Shared chrome for an AI-setup option card. Applies the selected/unselected
/// background + border + rounded corners, wires up hover/click, and emits the
/// provided select action.
fn render_card_chrome(
appearance: &Appearance,
is_selected: bool,
mouse_state: MouseStateHandle,
select_action: AiSetupSlideAction,
content: Box<dyn Element>,
) -> Box<dyn Element> {
const RADIUS: f32 = 8.;
let theme = appearance.theme();
let background = if is_selected {
Some(internal_colors::accent_overlay_1(theme))
} else {
None
};
let border_color = if is_selected {
theme.accent()
} else {
Fill::Solid(internal_colors::neutral_4(theme))
};
Hoverable::new(mouse_state, move |_| {
let mut container = Container::new(content)
.with_uniform_padding(24.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(RADIUS)))
.with_border(Border::all(1.).with_border_fill(border_color));
if let Some(bg) = background {
container = container.with_background(bg);
}
container.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(select_action.clone());
})
.finish()
}
fn render_warp_agent_card(
&self,
appearance: &Appearance,
is_selected: bool,
mouse_state: MouseStateHandle,
) -> Box<dyn Element> {
let theme = appearance.theme();
let bg_solid = theme.background().into_solid();
let label_color = if is_selected {
internal_colors::text_main(theme, bg_solid)
} else {
internal_colors::text_sub(theme, bg_solid)
};
let description_color = internal_colors::text_sub(theme, bg_solid);
let header_row = {
let label = appearance
.ui_builder()
.paragraph("Use Warp Agent")
.with_style(UiComponentStyles {
font_size: Some(16.),
font_weight: Some(Weight::Semibold),
font_color: Some(label_color),
..Default::default()
})
.build()
.finish();
let badge = {
let green = theme.ansi_fg_green();
let badge_text = appearance
.ui_builder()
.paragraph("Access more models")
.with_style(UiComponentStyles {
font_size: Some(12.),
font_weight: Some(Weight::Normal),
font_color: Some(green),
..Default::default()
})
.build()
.finish();
Container::new(badge_text)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(11.)))
.with_border(Border::all(1.).with_border_fill(Fill::Solid(green)))
.with_horizontal_padding(8.)
.with_vertical_padding(3.)
.finish()
};
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(label)
.with_child(badge)
.finish()
};
let description = FormattedTextElement::from_str(
"State of the art agent harness deeply integrated into the terminal.",
appearance.ui_font_family(),
14.,
)
.with_color(description_color)
.with_weight(Weight::Normal)
.with_alignment(TextAlignment::Left)
.with_line_height_ratio(1.2)
.finish();
let checklist = {
// When the card is selected, use the theme's green to match the
// "Blended ANSI/green_fg" token in the design.
let check_fill = if is_selected {
Fill::Solid(theme.ansi_fg_green())
} else {
Fill::Solid(label_color)
};
let mut col = Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Start);
for &item in WARP_AGENT_FEATURES {
let icon_el = ConstrainedBox::new(Icon::Check.to_warpui_icon(check_fill).finish())
.with_width(16.)
.with_height(16.)
.finish();
let text_el = appearance
.ui_builder()
.paragraph(item.to_string())
.with_style(UiComponentStyles {
font_size: Some(14.),
font_weight: Some(Weight::Normal),
font_color: Some(label_color),
..Default::default()
})
.build()
.finish();
let row = Flex::row()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(icon_el)
.with_child(Container::new(text_el).with_margin_left(8.).finish())
.finish();
col = col.with_child(
Container::new(row)
.with_padding_top(4.)
.with_padding_bottom(4.)
.finish(),
);
}
col.finish()
};
let content = Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(header_row)
.with_child(Container::new(description).with_margin_top(12.).finish())
.with_child(Container::new(checklist).with_margin_top(12.).finish())
.finish();
Self::render_card_chrome(
appearance,
is_selected,
mouse_state,
AiSetupSlideAction::SelectWarpAgent,
content,
)
}
fn render_third_party_card(
&self,
appearance: &Appearance,
is_selected: bool,
mouse_state: MouseStateHandle,
) -> Box<dyn Element> {
let theme = appearance.theme();
let bg_solid = theme.background().into_solid();
let text_color = if is_selected {
internal_colors::text_main(theme, bg_solid)
} else {
internal_colors::text_sub(theme, bg_solid)
};
let label = appearance
.ui_builder()
.paragraph("Use third party agents")
.with_style(UiComponentStyles {
font_size: Some(16.),
font_weight: Some(Weight::Semibold),
font_color: Some(text_color),
..Default::default()
})
.build()
.finish();
let description = FormattedTextElement::from_str(
"Use agents like Claude Code, Codex, and Gemini.",
appearance.ui_font_family(),
14.,
)
.with_color(text_color)
.with_weight(Weight::Normal)
.with_alignment(TextAlignment::Left)
.with_line_height_ratio(1.2)
.finish();
let content = Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(label)
.with_child(Container::new(description).with_margin_top(12.).finish())
.finish();
Self::render_card_chrome(
appearance,
is_selected,
mouse_state,
AiSetupSlideAction::SelectThirdParty,
content,
)
}
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(AiSetupSlideAction::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(AiSetupSlideAction::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, choice: AiSetupChoice) -> Box<dyn Element> {
match choice {
AiSetupChoice::WarpAgent => layout::onboarding_right_panel_with_bg(
Self::VISUAL_IMAGE_PATHS[0],
layout::FOREGROUND_LAYOUT_DEFAULT,
),
AiSetupChoice::ThirdParty => layout::onboarding_right_panel_with_bg(
Self::VISUAL_IMAGE_PATHS[1],
layout::FOREGROUND_LAYOUT_THIRD_PARTY,
),
}
}
}
impl Entity for AiSetupSlide {
type Event = ();
}
impl View for AiSetupSlide {
fn ui_name() -> &'static str {
"AiSetupSlide"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let choice = self.choice(app);
// Background is rendered by the parent onboarding view (including background images).
layout::static_left(
|| self.render_content(appearance, choice, app),
|| self.render_visual(choice),
)
}
}
impl AiSetupSlide {
fn select_choice(&mut self, choice: AiSetupChoice, ctx: &mut ViewContext<Self>) {
self.onboarding_state.update(ctx, |model, ctx| {
model.set_ai_setup_choice(choice, ctx);
});
ctx.notify();
}
fn next(&mut self, ctx: &mut ViewContext<Self>) {
self.onboarding_state.update(ctx, |model, ctx| {
model.next(ctx);
});
}
}
impl OnboardingSlide for AiSetupSlide {
fn on_up(&mut self, ctx: &mut ViewContext<Self>) {
self.select_choice(AiSetupChoice::WarpAgent, ctx);
}
fn on_down(&mut self, ctx: &mut ViewContext<Self>) {
self.select_choice(AiSetupChoice::ThirdParty, ctx);
}
fn on_enter(&mut self, ctx: &mut ViewContext<Self>) {
self.next(ctx);
}
}
impl TypedActionView for AiSetupSlide {
type Action = AiSetupSlideAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
AiSetupSlideAction::SelectWarpAgent => {
self.select_choice(AiSetupChoice::WarpAgent, ctx);
}
AiSetupSlideAction::SelectThirdParty => {
self.select_choice(AiSetupChoice::ThirdParty, ctx);
}
AiSetupSlideAction::BackClicked => {
self.onboarding_state.update(ctx, |model, ctx| {
model.back(ctx);
});
}
AiSetupSlideAction::NextClicked => {
self.next(ctx);
}
}
}
}
+14 -18
View File
@@ -1,11 +1,10 @@
use crate::slides::progress_dots;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::{
elements::{
Align, Container, CrossAxisAlignment, Empty, Flex, MainAxisSize, ParentElement, Shrinkable,
},
Element,
use galaxyui_core::elements::{
Align, CrossAxisAlignment, Empty, Flex, MainAxisSize, ParentElement, Shrinkable,
};
use galaxyui_core::Element;
use crate::slides::progress_dots;
pub fn onboarding_bottom_nav(
appearance: &Appearance,
@@ -19,19 +18,16 @@ pub fn onboarding_bottom_nav(
let back_button = back_button.unwrap_or_else(|| Empty::new().finish());
let next_button = next_button.unwrap_or_else(|| Empty::new().finish());
// Use equal-size flex slots on the left and right so the dots remain centered regardless of the
// button widths.
// Equal-weight side slots push Back to the far left and Next to the far
// right, leaving the natural-width dots centered between them on one row.
let left = Shrinkable::new(1., Align::new(back_button).left().finish()).finish();
let right = Shrinkable::new(1., Align::new(next_button).right().finish()).finish();
Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(left)
.with_child(dots)
.with_child(right)
.finish(),
)
.finish()
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(left)
.with_child(dots)
.with_child(right)
.finish()
}
+43 -49
View File
@@ -1,25 +1,27 @@
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 galaxyui_core::elements::{
ClippedScrollStateHandle, Container, CrossAxisAlignment, Flex, FormattedTextElement,
MainAxisSize, MouseStateHandle, ParentElement,
};
use galaxyui_core::fonts::Weight;
use galaxyui_core::keymap::Keystroke;
use galaxyui_core::prelude::Align;
use galaxyui_core::text_layout::TextAlignment;
use galaxyui_core::ui_components::components::{UiComponent as _, UiComponentStyles};
use galaxyui_core::{
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
ViewContext,
};
use super::toggle_card::{render_toggle_card, ChipSpec, ToggleCardSpec};
use super::OnboardingSlide;
use crate::model::{OnboardingStateEvent, OnboardingStateModel, UICustomizationSettings};
use crate::slides::{bottom_nav, layout, slide_content};
use crate::visuals::{intention_terminal_visual, intention_visual};
use crate::OnboardingIntention;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::{appearance::Appearance, theme::color::internal_colors};
use galaxyui::prelude::Align;
use galaxyui::{
elements::{
ClippedScrollStateHandle, Container, CrossAxisAlignment, Flex, FormattedTextElement,
MainAxisSize, MouseStateHandle, ParentElement,
},
fonts::Weight,
keymap::Keystroke,
text_layout::TextAlignment,
ui_components::components::{UiComponent as _, UiComponentStyles},
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
ViewContext,
};
use ui_components::{button, Component as _, Options as _};
/// Which setting card is currently selected (expanded).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -127,8 +129,9 @@ impl CustomizeUISlide {
appearance: &Appearance,
intention: OnboardingIntention,
ui: &UICustomizationSettings,
app: &AppContext,
) -> Box<dyn Element> {
let bottom_nav = Align::new(self.render_bottom_nav(appearance, intention)).finish();
let bottom_nav = Align::new(self.render_bottom_nav(appearance, app)).finish();
slide_content::onboarding_slide_content(
vec![
@@ -255,6 +258,24 @@ impl CustomizeUISlide {
let mut chips = vec![];
if ui.tools_panel_enabled(&intention) {
chips.push(ChipSpec {
label: "File explorer",
is_enabled: ui.show_project_explorer,
mouse_state: self.chip_file_explorer_mouse.clone(),
on_click: Box::new(|ctx, _, _| {
ctx.dispatch_typed_action(CustomizeSlideAction::ToggleToolsSubSetting {
setting: ToolsPanelSubSetting::ProjectExplorer,
});
}),
on_hover: Some(Box::new(|is_hovered, ctx, _, _| {
if is_hovered {
ctx.dispatch_typed_action(CustomizeSlideAction::HoverToolsChip {
setting: ToolsPanelSubSetting::ProjectExplorer,
});
}
})),
});
// Conversation history chip is only shown for the agent intention.
if is_agent {
chips.push(ChipSpec {
@@ -276,24 +297,6 @@ impl CustomizeUISlide {
});
}
chips.push(ChipSpec {
label: "File explorer",
is_enabled: ui.show_project_explorer,
mouse_state: self.chip_file_explorer_mouse.clone(),
on_click: Box::new(|ctx, _, _| {
ctx.dispatch_typed_action(CustomizeSlideAction::ToggleToolsSubSetting {
setting: ToolsPanelSubSetting::ProjectExplorer,
});
}),
on_hover: Some(Box::new(|is_hovered, ctx, _, _| {
if is_hovered {
ctx.dispatch_typed_action(CustomizeSlideAction::HoverToolsChip {
setting: ToolsPanelSubSetting::ProjectExplorer,
});
}
})),
});
chips.push(ChipSpec {
label: "Global file search",
is_enabled: ui.show_global_search,
@@ -402,11 +405,7 @@ impl CustomizeUISlide {
// --- Bottom nav ---
fn render_bottom_nav(
&self,
appearance: &Appearance,
intention: OnboardingIntention,
) -> Box<dyn Element> {
fn render_bottom_nav(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let back_button = self.back_button.render(
appearance,
button::Params {
@@ -437,8 +436,7 @@ impl CustomizeUISlide {
},
);
let is_terminal = matches!(intention, OnboardingIntention::Terminal);
let (step_index, step_count) = if is_terminal { (1, 4) } else { (1, 5) };
let (step_index, step_count) = self.onboarding_state.as_ref(app).progress();
bottom_nav::onboarding_bottom_nav(
appearance,
step_index,
@@ -537,12 +535,8 @@ impl CustomizeUISlide {
"async/png/onboarding/terminal_intention/terminal_customize_horizontal_tabs.png"
}
} else {
// Default chip: conversation for agent, file explorer for terminal.
let default_chip = if is_agent {
ToolsPanelSubSetting::ConversationHistory
} else {
ToolsPanelSubSetting::ProjectExplorer
};
// Default chip: file explorer for both intents (matches the new tools panel order).
let default_chip = ToolsPanelSubSetting::ProjectExplorer;
let chip = hovered_chip.unwrap_or(default_chip);
if is_agent {
match (chip, vertical) {
@@ -644,7 +638,7 @@ impl View for CustomizeUISlide {
let ui = self.model_ui_customization(app);
layout::static_left(
|| self.render_content(appearance, intention, &ui),
|| self.render_content(appearance, intention, &ui, app),
|| self.render_visual(appearance, intention, &ui),
)
}
+32 -23
View File
@@ -1,27 +1,30 @@
use super::OnboardingSlide;
use crate::model::OnboardingStateModel;
use crate::slides::{bottom_nav, layout, slide_content};
use crate::visuals::{intention_terminal_visual, intention_visual};
use crate::{OnboardingIntention, AI_FEATURES};
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 galaxy_core::ui::{appearance::Appearance, theme::color::internal_colors, Icon};
use galaxyui::prelude::Align;
use galaxyui::{
elements::{
Border, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Flex, FormattedTextElement, Hoverable, MainAxisAlignment, MainAxisSize,
MouseStateHandle, ParentElement, Radius,
},
fonts::Weight,
keymap::Keystroke,
platform::Cursor,
text_layout::TextAlignment,
ui_components::components::{UiComponent as _, UiComponentStyles},
use galaxy_core::ui::Icon;
use galaxyui_core::elements::{
Border, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Flex, FormattedTextElement, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle,
ParentElement, Radius,
};
use galaxyui_core::fonts::Weight;
use galaxyui_core::keymap::Keystroke;
use galaxyui_core::platform::Cursor;
use galaxyui_core::prelude::Align;
use galaxyui_core::text_layout::TextAlignment;
use galaxyui_core::ui_components::components::{UiComponent as _, UiComponentStyles};
use galaxyui_core::{
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
ViewContext,
};
use ui_components::{button, Component as _, Options as _};
use super::OnboardingSlide;
use crate::model::{NoAiConfirmationSource, OnboardingStateModel};
use crate::slides::{bottom_nav, layout, slide_content};
use crate::visuals::{intention_terminal_visual, intention_visual};
use crate::{OnboardingIntention, AI_FEATURES};
#[derive(Debug, Clone)]
pub enum IntentionSlideAction {
@@ -199,7 +202,7 @@ impl IntentionSlide {
let header_row = {
let label = appearance
.ui_builder()
.paragraph("Build faster with AI agents")
.paragraph("Build faster with agents")
.with_style(UiComponentStyles {
font_size: Some(16.),
font_weight: Some(Weight::Semibold),
@@ -237,7 +240,7 @@ impl IntentionSlide {
};
let description = FormattedTextElement::from_str(
"An agent-first experience with best in class terminal support. Get terminal and agent driven development AI features like:",
"Get AI features to accelerate terminal and agent-driven workflows:",
appearance.ui_font_family(),
14.,
)
@@ -521,8 +524,14 @@ impl IntentionSlide {
fn next(&mut self, ctx: &mut ViewContext<Self>) {
self.onboarding_state.update(ctx, |model, ctx| {
if FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
// Always advance to Customize slide; both intentions continue the flow.
model.next(ctx);
match model.intention() {
// "Just use the terminal" confirms leaving AI behind before advancing.
OnboardingIntention::Terminal => {
model.request_no_ai_confirmation(NoAiConfirmationSource::Intention, ctx);
}
// Agent intention routes to the next step (the AI-setup fork).
OnboardingIntention::AgentDrivenDevelopment => model.next(ctx),
}
} else {
match model.intention() {
OnboardingIntention::Terminal => {
+23 -19
View File
@@ -1,25 +1,29 @@
use crate::model::OnboardingStateModel;
use crate::OnboardingEvent;
use super::OnboardingSlide;
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::{appearance::Appearance, theme::color::internal_colors, Icon};
use galaxyui::{
elements::{
shimmering_text::{ShimmerConfig, ShimmeringTextElement, ShimmeringTextStateHandle},
Align, ChildAnchor, ConstrainedBox, Container, CrossAxisAlignment, Flex,
FormattedTextElement, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning,
ParentAnchor, ParentElement, ParentOffsetBounds, Stack,
},
keymap::Keystroke,
text_layout::TextAlignment,
ui_components::components::{UiComponent as _, UiComponentStyles},
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
ViewContext,
};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use ui_components::{button, Component as _, Options as _};
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::Icon;
use galaxyui_core::elements::shimmering_text::{
ShimmerConfig, ShimmeringTextElement, ShimmeringTextStateHandle,
};
use galaxyui_core::elements::{
Align, ChildAnchor, ConstrainedBox, Container, CrossAxisAlignment, Flex, FormattedTextElement,
MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Stack,
};
use galaxyui_core::keymap::Keystroke;
use galaxyui_core::text_layout::TextAlignment;
use galaxyui_core::ui_components::components::{UiComponent as _, UiComponentStyles};
use galaxyui_core::{
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
ViewContext,
};
use super::OnboardingSlide;
use crate::model::OnboardingStateModel;
use crate::OnboardingEvent;
#[derive(Clone, Debug)]
pub enum IntroSlideEvent {
+9 -9
View File
@@ -1,15 +1,15 @@
use galaxyui::{
assets::asset_cache::AssetSource,
elements::{
Align, CacheOption, Clipped, ConstrainedBox, Container, CrossAxisAlignment, Empty,
Expanded, Flex, Image, MainAxisSize, ParentElement, Point, Shrinkable,
SizeConstraintCondition, SizeConstraintSwitch, Stack,
},
event::DispatchedEvent,
use pathfinder_geometry::vector::{vec2f, Vector2F};
use galaxyui_core::assets::asset_cache::AssetSource;
use galaxyui_core::elements::{
Align, CacheOption, Clipped, ConstrainedBox, Container, CrossAxisAlignment, Empty, Expanded,
Flex, Image, MainAxisSize, ParentElement, Point, Shrinkable, SizeConstraintCondition,
SizeConstraintSwitch, Stack,
};
use galaxyui_core::event::DispatchedEvent;
use galaxyui_core::{
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext,
SizeConstraint,
};
use pathfinder_geometry::vector::{vec2f, Vector2F};
// Onboarding images live under `app/assets/async/` so they are excluded from the WASM
// binary (RustEmbed excludes `async/**` on wasm targets). They are still bundled normally
+5 -5
View File
@@ -1,7 +1,8 @@
mod agent_slide;
mod ai_access_slide;
mod ai_setup_slide;
mod bottom_nav;
mod customize_slide;
mod free_user_no_ai_slide;
mod intention_slide;
mod intro_slide;
pub mod layout;
@@ -14,12 +15,11 @@ mod third_party_slide;
mod toggle_card;
mod two_line_button;
pub use agent_slide::{
AgentAutonomy, AgentDevelopmentSettings, AgentSlide, AgentSlideEvent, OnboardingModelInfo,
};
pub use agent_slide::{AgentAutonomy, AgentDevelopmentSettings, AgentSlide, OnboardingModelInfo};
pub use ai_access_slide::{AiAccessSlide, AiAccessSlideEvent};
pub use ai_setup_slide::AiSetupSlide;
pub use bottom_nav::onboarding_bottom_nav;
pub use customize_slide::CustomizeUISlide;
pub use free_user_no_ai_slide::FreeUserNoAiSlide;
pub use intention_slide::IntentionSlide;
pub use intro_slide::{IntroSlide, IntroSlideEvent};
pub use onboarding_slide::OnboardingSlide;
@@ -1,4 +1,4 @@
use galaxyui::{View, ViewContext};
use galaxyui_core::{View, ViewContext};
pub trait OnboardingSlide: View {
fn on_up(&mut self, _ctx: &mut ViewContext<Self>) {}
@@ -1,10 +1,9 @@
use galaxy_core::ui::{appearance::Appearance, theme::color::internal_colors};
use galaxyui::{
elements::{
ConstrainedBox, Container, CornerRadius, Empty, Flex, MainAxisSize, ParentElement, Radius,
},
Element,
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::color::internal_colors;
use galaxyui_core::elements::{
ConstrainedBox, Container, CornerRadius, Empty, Flex, MainAxisSize, ParentElement, Radius,
};
use galaxyui_core::Element;
/// Render `n` dots with 4px radius and 8px spacing. `k` is the 0-based active dot index.
pub(crate) fn progress_dots(n: usize, k: usize, appearance: &Appearance) -> Box<dyn Element> {
+22 -21
View File
@@ -1,28 +1,29 @@
use ui_components::{button, keyboard_shortcut, Component as _, Options as _};
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::color::coloru_with_opacity;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::Icon;
use galaxyui_core::elements::{
Align, ClippedScrollStateHandle, ConstrainedBox, Container, CrossAxisAlignment, Flex,
MouseStateHandle, ParentElement, Shrinkable,
};
use galaxyui_core::fonts::Weight;
use galaxyui_core::keymap::Keystroke;
use galaxyui_core::platform::file_picker::{FilePickerConfiguration, FilePickerError};
use galaxyui_core::prelude::{MainAxisAlignment, MainAxisSize, Vector2F};
use galaxyui_core::ui_components::button::{ButtonVariant, TextAndIcon, TextAndIconAlignment};
use galaxyui_core::ui_components::components::{UiComponent as _, UiComponentStyles};
use galaxyui_core::{
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
ViewContext,
};
use super::OnboardingSlide;
use crate::model::OnboardingStateModel;
use crate::slides::{bottom_nav, layout, slide_content};
use crate::telemetry::OnboardingEvent;
use crate::visuals::project_visual;
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::{
appearance::Appearance, color::coloru_with_opacity, theme::color::internal_colors, Icon,
};
use galaxyui::prelude::{MainAxisAlignment, MainAxisSize, Vector2F};
use galaxyui::ui_components::button::{ButtonVariant, TextAndIcon, TextAndIconAlignment};
use galaxyui::{
elements::{
Align, ClippedScrollStateHandle, ConstrainedBox, Container, CrossAxisAlignment, Flex,
MouseStateHandle, ParentElement, Shrinkable,
},
fonts::Weight,
keymap::Keystroke,
platform::file_picker::{FilePickerConfiguration, FilePickerError},
ui_components::components::{UiComponent as _, UiComponentStyles},
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
ViewContext,
};
use ui_components::{button, keyboard_shortcut, Component as _, Options as _};
use super::OnboardingSlide;
const LEFT_COLUMN_W: f32 = 428.;
@@ -1,11 +1,9 @@
use galaxy_core::ui::appearance::Appearance;
use galaxyui::{
elements::{
Align, ClippedScrollStateHandle, ClippedScrollable, Container, CrossAxisAlignment, Flex,
MainAxisSize, ParentElement, ScrollbarWidth, Shrinkable,
},
Element,
use galaxyui_core::elements::{
Align, ClippedScrollStateHandle, ClippedScrollable, Container, CrossAxisAlignment, Flex,
MainAxisSize, ParentElement, ScrollbarWidth, Shrinkable,
};
use galaxyui_core::Element;
pub fn onboarding_slide_content(
children: Vec<Box<dyn Element>>,
@@ -57,7 +55,7 @@ pub fn onboarding_slide_content(
.with_child(Shrinkable::new(1., scrollable).finish())
.with_child(
Container::new(bottom_nav)
.with_margin_top(24.)
.with_margin_top(16.)
.with_padding_right(PADDING)
.finish(),
)
@@ -67,7 +65,7 @@ pub fn onboarding_slide_content(
// inside the scrollable and bottom nav so the scrollbar stays at the edge.
Container::new(outer)
.with_padding_top(PADDING)
.with_padding_bottom(PADDING)
.with_padding_bottom(PADDING - 16.)
.with_padding_left(PADDING)
.finish()
}
@@ -1,27 +1,30 @@
use pathfinder_color::ColorU;
use ui_components::{button, Component as _, Options as _};
use galaxy_core::features::FeatureFlag;
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::WarpTheme;
use galaxyui_core::elements::{
Border, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Empty, Flex, FormattedTextElement, Hoverable, MainAxisAlignment, MainAxisSize,
MouseStateHandle, ParentElement, Radius, Text,
};
use galaxyui_core::fonts::{Properties, Weight};
use galaxyui_core::keymap::Keystroke;
use galaxyui_core::platform::Cursor;
use galaxyui_core::text_layout::TextAlignment;
use galaxyui_core::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui_core::{
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
};
use super::OnboardingSlide;
use crate::model::{OnboardingStateEvent, OnboardingStateModel};
use crate::slides::{bottom_nav, layout, slide_content};
use crate::telemetry::OnboardingEvent;
use crate::visuals::theme_picker_visual;
use crate::OnboardingIntention;
use galaxy_core::features::FeatureFlag;
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::{appearance::Appearance, theme::color::internal_colors, theme::GalaxyTheme};
use galaxyui::{
elements::{
Border, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Empty, Flex, FormattedTextElement, Hoverable, MainAxisAlignment,
MainAxisSize, MouseStateHandle, ParentElement, Radius, Text,
},
fonts::{Properties, Weight},
keymap::Keystroke,
platform::Cursor,
text_layout::TextAlignment,
ui_components::components::{UiComponent, UiComponentStyles},
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
};
use pathfinder_color::ColorU;
use ui_components::{button, Component as _, Options as _};
#[derive(Debug, Clone)]
pub enum ThemePickerSlideEvent {
@@ -134,12 +137,15 @@ impl ThemePickerSlide {
app: &AppContext,
) -> Box<dyn Element> {
// The option "chrome" (background, borders, text) should be styled using the currently
// selected theme.
let selected_theme = self
.theme_options
.get(self.selected_theme_index)
.map(|option| option.theme.clone())
.unwrap_or_else(|| self.theme_options[0].theme.clone());
// selected theme, if sync_with_os is not selected.
let selected_theme = if self.sync_with_os {
appearance.theme().clone()
} else {
self.theme_options
.get(self.selected_theme_index)
.map(|option| option.theme.clone())
.unwrap_or_else(|| self.theme_options[0].theme.clone())
};
let bottom_nav = self.render_bottom_nav(appearance, app);
@@ -1,26 +1,26 @@
use ui_components::{button, Component as _, Options as _};
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::color::internal_colors;
use galaxyui_core::elements::{
ClippedScrollStateHandle, Container, CrossAxisAlignment, Flex, FormattedTextElement,
MainAxisSize, MouseStateHandle, ParentElement,
};
use galaxyui_core::fonts::Weight;
use galaxyui_core::keymap::Keystroke;
use galaxyui_core::prelude::Align;
use galaxyui_core::text_layout::TextAlignment;
use galaxyui_core::ui_components::components::{UiComponent as _, UiComponentStyles};
use galaxyui_core::{
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
ViewContext,
};
use super::toggle_card::{render_toggle_card, ToggleCardSpec};
use super::OnboardingSlide;
use crate::model::{OnboardingStateEvent, OnboardingStateModel};
use crate::slides::{bottom_nav, layout, slide_content};
use crate::OnboardingIntention;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::color::internal_colors;
use galaxyui::prelude::Align;
use galaxyui::{
elements::{
ClippedScrollStateHandle, Container, CrossAxisAlignment, Flex, FormattedTextElement,
MainAxisSize, MouseStateHandle, ParentElement,
},
fonts::Weight,
keymap::Keystroke,
text_layout::TextAlignment,
ui_components::components::{UiComponent as _, UiComponentStyles},
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
ViewContext,
};
use ui_components::{button, Component as _, Options as _};
/// Which setting card is currently expanded.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SettingCard {
@@ -111,8 +111,9 @@ impl ThirdPartySlide {
cli_toolbar_enabled: bool,
show_agent_notifications: bool,
intention: OnboardingIntention,
app: &AppContext,
) -> Box<dyn Element> {
let bottom_nav = Align::new(self.render_bottom_nav(appearance, intention)).finish();
let bottom_nav = Align::new(self.render_bottom_nav(appearance, app)).finish();
let mut sections = vec![
self.render_header(appearance),
@@ -263,11 +264,7 @@ impl ThirdPartySlide {
.finish()
}
fn render_bottom_nav(
&self,
appearance: &Appearance,
intention: OnboardingIntention,
) -> Box<dyn Element> {
fn render_bottom_nav(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let back_button = self.back_button.render(
appearance,
button::Params {
@@ -298,8 +295,7 @@ impl ThirdPartySlide {
},
);
let is_terminal = matches!(intention, OnboardingIntention::Terminal);
let (step_index, step_count) = if is_terminal { (2, 4) } else { (3, 5) };
let (step_index, step_count) = self.onboarding_state.as_ref(app).progress();
bottom_nav::onboarding_bottom_nav(
appearance,
step_index,
@@ -361,6 +357,7 @@ impl View for ThirdPartySlide {
cli_toolbar_enabled,
show_agent_notifications,
intention,
app,
)
},
|| self.render_visual(cli_toolbar_enabled, show_agent_notifications, vertical),
+14 -15
View File
@@ -1,19 +1,18 @@
use galaxy_core::ui::theme::Fill;
use galaxy_core::ui::{appearance::Appearance, theme::color::internal_colors};
use galaxyui::prelude::Align;
use galaxyui::{
elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex,
FormattedTextElement, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle,
ParentElement, Radius, Shrinkable, Text, Wrap,
},
fonts::Weight,
platform::Cursor,
presenter::EventContext,
text_layout::TextAlignment,
AppContext, Element,
};
use pathfinder_geometry::vector::Vector2F;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::Fill;
use galaxyui_core::elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex,
FormattedTextElement, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle,
ParentElement, Radius, Shrinkable, Text, Wrap,
};
use galaxyui_core::fonts::Weight;
use galaxyui_core::platform::Cursor;
use galaxyui_core::prelude::Align;
use galaxyui_core::presenter::EventContext;
use galaxyui_core::text_layout::TextAlignment;
use galaxyui_core::{AppContext, Element};
pub(super) type ClickCallback = Box<dyn FnMut(&mut EventContext, &AppContext, Vector2F) + 'static>;
pub(super) type HoverCallback =
+14 -16
View File
@@ -1,20 +1,18 @@
use super::agent_slide::AgentSlideAction;
use galaxy_core::ui::{
appearance::Appearance,
icons::Icon,
theme::{color::internal_colors, Fill},
};
use galaxyui::{
elements::{
Align, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Flex, Hoverable, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Radius, Stack, Text,
},
fonts::{Properties, Weight},
platform::Cursor,
Element,
};
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::icons::Icon;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::Fill;
use galaxyui_core::elements::{
Align, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex,
Hoverable, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Radius, Stack, Text,
};
use galaxyui_core::fonts::{Properties, Weight};
use galaxyui_core::platform::Cursor;
use galaxyui_core::Element;
use super::agent_slide::AgentSlideAction;
pub(super) struct TwoLineButtonSpec {
pub(super) is_selected: bool,
+37 -12
View File
@@ -20,6 +20,9 @@ pub enum OnboardingEvent {
model: Option<String>,
autonomy: Option<String>,
has_project_path: bool,
/// How the user is accessing AI when intention is agent_driven:
/// "warp_agent" or "third_party". None when intention is not agent_driven.
ai_access: Option<String>,
},
/// The user clicked the "Get Started" button.
GetStartedClicked,
@@ -37,8 +40,12 @@ pub enum OnboardingEvent {
SlideNavigatedNext,
/// The user navigated to the previous slide.
SlideNavigatedBack,
/// The user clicked the upgrade/subscribe button on the FreeUserNoAi experiment slide.
FreeUserNoAiUpgradeClicked,
/// The user was shown the "Are you sure you don't want AI?" confirmation modal.
NoAiConfirmationShown,
/// The user confirmed they don't want AI in the confirmation modal.
NoAiConfirmed,
/// The user chose to keep AI ("Give me AI features") in the confirmation modal.
NoAiConfirmationCancelled,
/// The user clicked the "Upgrade" button on the "Customize your agent" slide.
AgentSlideUpgradeClicked,
/// The user clicked the "Log in" link on the welcome/intro slide.
@@ -60,9 +67,9 @@ impl TelemetryEvent for OnboardingEvent {
OnboardingEvent::CalloutCompleted { .. } => "onboarding_callout_completed",
OnboardingEvent::SlideNavigatedNext => "onboarding_slide_navigated_next",
OnboardingEvent::SlideNavigatedBack => "onboarding_slide_navigated_back",
OnboardingEvent::FreeUserNoAiUpgradeClicked => {
"onboarding_free_user_no_ai_upgrade_clicked"
}
OnboardingEvent::NoAiConfirmationShown => "onboarding_no_ai_confirmation_shown",
OnboardingEvent::NoAiConfirmed => "onboarding_no_ai_confirmed",
OnboardingEvent::NoAiConfirmationCancelled => "onboarding_no_ai_confirmation_cancelled",
OnboardingEvent::AgentSlideUpgradeClicked => "onboarding_agent_slide_upgrade_clicked",
OnboardingEvent::WelcomeLoginClicked => "onboarding_welcome_login_clicked",
}
@@ -83,11 +90,13 @@ impl TelemetryEvent for OnboardingEvent {
model,
autonomy,
has_project_path,
ai_access,
} => Some(json!({
"intention": intention,
"model": model,
"autonomy": autonomy,
"has_project_path": has_project_path,
"ai_access": ai_access,
})),
OnboardingEvent::GetStartedClicked => None,
OnboardingEvent::FolderSelectionStarted => None,
@@ -101,7 +110,9 @@ impl TelemetryEvent for OnboardingEvent {
})),
OnboardingEvent::SlideNavigatedNext => None,
OnboardingEvent::SlideNavigatedBack => None,
OnboardingEvent::FreeUserNoAiUpgradeClicked => None,
OnboardingEvent::NoAiConfirmationShown => None,
OnboardingEvent::NoAiConfirmed => None,
OnboardingEvent::NoAiConfirmationCancelled => None,
OnboardingEvent::AgentSlideUpgradeClicked => None,
OnboardingEvent::WelcomeLoginClicked => None,
}
@@ -123,8 +134,12 @@ impl TelemetryEvent for OnboardingEvent {
OnboardingEvent::CalloutCompleted { .. } => "User completed the callout flow",
OnboardingEvent::SlideNavigatedNext => "User navigated to the next slide",
OnboardingEvent::SlideNavigatedBack => "User navigated to the previous slide",
OnboardingEvent::FreeUserNoAiUpgradeClicked => {
"User clicked the upgrade button on the free-user no-AI experiment slide"
OnboardingEvent::NoAiConfirmationShown => "User was shown the no-AI confirmation modal",
OnboardingEvent::NoAiConfirmed => {
"User confirmed they don't want AI in the confirmation modal"
}
OnboardingEvent::NoAiConfirmationCancelled => {
"User chose to keep AI in the confirmation modal"
}
OnboardingEvent::AgentSlideUpgradeClicked => {
"User clicked the Upgrade button on the Customize your agent slide"
@@ -165,8 +180,12 @@ impl TelemetryEventDesc for OnboardingEventDiscriminant {
OnboardingEventDiscriminant::CalloutCompleted => "onboarding_callout_completed",
OnboardingEventDiscriminant::SlideNavigatedNext => "onboarding_slide_navigated_next",
OnboardingEventDiscriminant::SlideNavigatedBack => "onboarding_slide_navigated_back",
OnboardingEventDiscriminant::FreeUserNoAiUpgradeClicked => {
"onboarding_free_user_no_ai_upgrade_clicked"
OnboardingEventDiscriminant::NoAiConfirmationShown => {
"onboarding_no_ai_confirmation_shown"
}
OnboardingEventDiscriminant::NoAiConfirmed => "onboarding_no_ai_confirmed",
OnboardingEventDiscriminant::NoAiConfirmationCancelled => {
"onboarding_no_ai_confirmation_cancelled"
}
OnboardingEventDiscriminant::AgentSlideUpgradeClicked => {
"onboarding_agent_slide_upgrade_clicked"
@@ -197,8 +216,14 @@ impl TelemetryEventDesc for OnboardingEventDiscriminant {
OnboardingEventDiscriminant::SlideNavigatedBack => {
"User navigated to the previous slide"
}
OnboardingEventDiscriminant::FreeUserNoAiUpgradeClicked => {
"User clicked the upgrade button on the free-user no-AI experiment slide"
OnboardingEventDiscriminant::NoAiConfirmationShown => {
"User was shown the no-AI confirmation modal"
}
OnboardingEventDiscriminant::NoAiConfirmed => {
"User confirmed they don't want AI in the confirmation modal"
}
OnboardingEventDiscriminant::NoAiConfirmationCancelled => {
"User chose to keep AI in the confirmation modal"
}
OnboardingEventDiscriminant::AgentSlideUpgradeClicked => {
"User clicked the Upgrade button on the Customize your agent slide"
+1 -1
View File
@@ -1,5 +1,5 @@
use galaxy_core::telemetry::{TelemetryContextModel, TelemetryContextProvider};
use galaxyui::{AppContext, ModelContext};
use galaxyui_core::{AppContext, ModelContext};
/// A mock telemetry context provider for the onboarding binary that logs events
/// instead of sending them to a server.
@@ -1,6 +1,8 @@
use galaxyui::elements::Align;
use galaxyui::Element;
use pathfinder_color::ColorU;
use galaxyui_core::elements::Align;
use galaxyui_core::Element;
use super::onboarding_visual::{OnboardingVisual, Pill, RectPct};
@@ -1,10 +1,11 @@
use galaxyui::elements::Align;
use galaxyui::Element;
use pathfinder_color::ColorU;
use crate::visuals::onboarding_visual::Rect;
use galaxyui_core::elements::Align;
use galaxyui_core::Element;
use super::onboarding_visual::{OnboardingVisual, Pill, RectPct};
use crate::visuals::onboarding_visual::Rect;
pub(crate) fn intention_terminal_visual(
panel_background: ColorU,
@@ -1,6 +1,8 @@
use galaxyui::elements::Align;
use galaxyui::Element;
use pathfinder_color::ColorU;
use galaxyui_core::elements::Align;
use galaxyui_core::Element;
use super::onboarding_visual::{OnboardingVisual, Pill, RectPct};
@@ -1,15 +1,15 @@
use pathfinder_color::ColorU;
use galaxy_core::ui::Icon;
use galaxyui::assets::asset_cache::{AssetCache, AssetSource, AssetState};
use galaxyui::geometry::rect::RectF;
use galaxyui::geometry::vector::{vec2f, Vector2F};
use galaxyui::image_cache::{AnimatedImageBehavior, CacheOption, FitType, Image, ImageCache};
use galaxyui::{
elements::{CornerRadius, Fill, Point, Radius},
event::DispatchedEvent,
use galaxyui_core::assets::asset_cache::{AssetCache, AssetSource, AssetState};
use galaxyui_core::elements::{CornerRadius, Fill, Point, Radius};
use galaxyui_core::event::DispatchedEvent;
use galaxyui_core::geometry::rect::RectF;
use galaxyui_core::geometry::vector::{vec2f, Vector2F};
use galaxyui_core::image_cache::{AnimatedImageBehavior, CacheOption, FitType, Image, ImageCache};
use galaxyui_core::{
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext,
SingletonEntity as _, SizeConstraint,
};
use pathfinder_color::ColorU;
#[derive(Debug, Clone, Copy)]
pub(crate) struct RectPct {
@@ -2,6 +2,8 @@ use galaxy_core::ui::Icon;
use galaxyui::elements::Align;
use galaxyui::Element;
use pathfinder_color::ColorU;
use galaxyui_core::elements::Align;
use galaxyui_core::Element;
use super::onboarding_visual::{IconPct, OnboardingVisual, Pill, RectPct};
@@ -1,6 +1,9 @@
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::color::internal_colors;
use galaxyui_core::elements::Align;
use galaxyui_core::Element;
use super::onboarding_visual::{OnboardingVisual, Pill, RectPct};
use galaxy_core::ui::{appearance::Appearance, theme::color::internal_colors};
use galaxyui::{elements::Align, Element};
pub(crate) fn theme_picker_visual(appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();