Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -0,0 +1,590 @@
use crate::model::{
OnboardingAuthState, OnboardingStateEvent, OnboardingStateModel, OnboardingStep,
SelectedSettings,
};
use crate::slides::{
AgentSlide, AgentSlideEvent, CustomizeUISlide, FreeUserNoAiSlide, IntentionSlide, IntroSlide,
IntroSlideEvent, OnboardingModelInfo, OnboardingSlide, ProjectSlide, ThemePickerSlide,
ThemePickerSlideEvent, ThirdPartySlide,
};
use crate::telemetry::OnboardingEvent;
use ai::LLMId;
use instant::Instant;
use std::time::Duration;
use warp_core::features::FeatureFlag;
use warp_core::send_telemetry_from_ctx;
use warpui::assets::asset_cache::AssetSource;
use warpui::image_cache::ImageType;
use warpui::windowing::{
state::{ApplicationStage, StateEvent},
WindowManager,
};
const APP_BECAME_ACTIVE_DEBOUNCE: Duration = Duration::from_secs(15);
use pathfinder_geometry::vector::vec2f;
use ui_components::{button, Component as _, Options as _};
use warp_core::ui::{appearance::Appearance, theme::WarpTheme};
use warpui::elements::Rect;
use warpui::{
elements::{
CacheOption, ChildAnchor, Container, Empty, Image, OffsetPositioning, ParentAnchor,
ParentElement, ParentOffsetBounds, Shrinkable, Stack,
},
keymap::Keystroke,
keymap::{macros::*, FixedBinding},
presenter::ChildView,
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
ViewContext, ViewHandle,
};
#[derive(Clone, Debug)]
pub enum AgentOnboardingEvent {
ThemeSelected {
theme_name: String,
},
SyncWithOsToggled {
enabled: bool,
},
OnboardingCompleted(SelectedSettings),
OnboardingSkipped,
LoginFromWelcomeRequested,
/// Emitted when the user clicks the "Privacy Settings" link on the terminal
/// intention theme slide. The variant name encodes that the event is only
/// emitted from the terminal-intention theme slide; consumers (e.g. a
/// `LoginSlideView` with `LoginSlideSource::PrivacySettingsFromTerminalIntentionTheme`)
/// rely on that to select the right visual / back-routing behavior.
PrivacySettingsFromTerminalThemeSlideRequested,
UpgradeRequested,
UpgradeCopyUrlRequested,
UpgradePasteTokenFromClipboardRequested,
/// Emitted when the app regains focus (e.g. user returns from the browser).
/// The parent should refresh any stale data: available models, workspace/billing metadata, etc.
AppBecameActive,
}
pub struct AgentOnboardingView {
onboarding_state: ModelHandle<OnboardingStateModel>,
intro_slide: ViewHandle<IntroSlide>,
theme_picker_slide: ViewHandle<ThemePickerSlide>,
intention_slide: ViewHandle<IntentionSlide>,
customize_slide: ViewHandle<CustomizeUISlide>,
free_user_no_ai_slide: ViewHandle<FreeUserNoAiSlide>,
agent_slide: ViewHandle<AgentSlide>,
third_party_slide: ViewHandle<ThirdPartySlide>,
project_slide: ViewHandle<ProjectSlide>,
skippable: bool,
close_button: button::Button,
last_model_refresh: Option<Instant>,
}
#[derive(Clone, Copy, Debug)]
pub enum AgentOnboardingAction {
UpKey,
DownKey,
LeftKey,
RightKey,
TabKey,
EnterKey,
CmdOrCtrlEnterKey,
Escape,
}
fn dispatch_onboarding_action_to_slide<V: OnboardingSlide>(
slide: &mut V,
action: AgentOnboardingAction,
ctx: &mut ViewContext<V>,
) {
match action {
AgentOnboardingAction::UpKey => slide.on_up(ctx),
AgentOnboardingAction::DownKey => slide.on_down(ctx),
AgentOnboardingAction::LeftKey => slide.on_left(ctx),
AgentOnboardingAction::RightKey => slide.on_right(ctx),
AgentOnboardingAction::TabKey => slide.on_tab(ctx),
AgentOnboardingAction::EnterKey => slide.on_enter(ctx),
AgentOnboardingAction::CmdOrCtrlEnterKey => slide.on_cmd_or_ctrl_enter(ctx),
AgentOnboardingAction::Escape => slide.on_escape(ctx),
}
}
impl AgentOnboardingView {
/// Creates a new AgentOnboardingView.
#[allow(clippy::too_many_arguments)]
pub fn new(
theme_picker_themes: [WarpTheme; 4],
skippable: bool,
models: Vec<OnboardingModelInfo>,
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 {
let onboarding_state = ctx.add_model(|_| {
OnboardingStateModel::new(
models,
default_model_id,
workspace_enforces_autonomy,
agent_modality_enabled,
free_user_no_ai_experiment,
agent_price_cents,
auth_state,
)
});
ctx.subscribe_to_model(&onboarding_state, |me, _model, event, ctx| {
// Re-render when slide selection changes.
if !ctx.is_self_or_child_focused() {
ctx.focus_self();
}
ctx.notify();
match event {
OnboardingStateEvent::Completed => {
me.handle_onboarding_completed(ctx);
}
OnboardingStateEvent::UpgradeRequested => {
ctx.emit(AgentOnboardingEvent::UpgradeRequested);
}
_ => {}
}
});
let intro_slide = {
let onboarding_state = onboarding_state.clone();
ctx.add_typed_action_view(move |_| IntroSlide::new(onboarding_state))
};
ctx.subscribe_to_view(&intro_slide, |_me, _view, event, ctx| match event {
IntroSlideEvent::LoginRequested => {
ctx.emit(AgentOnboardingEvent::LoginFromWelcomeRequested);
}
});
let theme_picker_slide = {
let themes = theme_picker_themes.clone();
let onboarding_state = onboarding_state.clone();
ctx.add_typed_action_view(move |ctx| {
ThemePickerSlide::new(themes.clone(), onboarding_state, ctx)
})
};
let intention_slide = {
let onboarding_state = onboarding_state.clone();
ctx.add_typed_action_view(move |_| IntentionSlide::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);
});
let agent_slide = {
let onboarding_state = onboarding_state.clone();
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 => {
ctx.emit(AgentOnboardingEvent::UpgradeCopyUrlRequested);
}
AgentSlideEvent::PasteAuthTokenFromClipboardRequested => {
ctx.emit(AgentOnboardingEvent::UpgradePasteTokenFromClipboardRequested);
}
});
let third_party_slide = {
let onboarding_state = onboarding_state.clone();
ctx.add_typed_action_view(move |ctx| ThirdPartySlide::new(onboarding_state, ctx))
};
let project_slide = {
let onboarding_state = onboarding_state.clone();
ctx.add_typed_action_view(move |_| ProjectSlide::new(onboarding_state))
};
// When the app regains focus (e.g. user returning from the upgrade page in the
// browser), notify the parent to refresh models and workspace/billing metadata.
// Debounced to avoid excessive API calls from rapid alt-tabbing.
ctx.subscribe_to_model(&WindowManager::handle(ctx), |me, _wm, event, ctx| {
let StateEvent::ValueChanged { current, previous } = event;
if previous.stage != ApplicationStage::Active
&& current.stage == ApplicationStage::Active
{
let now = Instant::now();
let should_refresh = me
.last_model_refresh
.is_none_or(|last| now.duration_since(last) >= APP_BECAME_ACTIVE_DEBOUNCE);
if should_refresh {
me.last_model_refresh = Some(now);
ctx.emit(AgentOnboardingEvent::AppBecameActive);
}
}
});
Self {
onboarding_state,
intro_slide,
theme_picker_slide,
intention_slide,
customize_slide,
free_user_no_ai_slide,
agent_slide,
third_party_slide,
project_slide,
skippable,
close_button: button::Button::default(),
last_model_refresh: None,
}
}
/// Updates the list of available models.
pub fn set_onboarding_models(
&mut self,
models: Vec<OnboardingModelInfo>,
default_model_id: LLMId,
ctx: &mut ViewContext<Self>,
) {
self.onboarding_state.update(ctx, |state, ctx| {
state.set_models(models, default_model_id, ctx);
});
ctx.notify();
}
pub fn set_workspace_enforces_autonomy(&mut self, value: bool, ctx: &mut ViewContext<Self>) {
self.onboarding_state.update(ctx, |state, ctx| {
state.set_workspace_enforces_autonomy(value, ctx);
});
ctx.notify();
}
pub fn set_auth_state(&mut self, auth_state: OnboardingAuthState, ctx: &mut ViewContext<Self>) {
self.onboarding_state.update(ctx, |state, ctx| {
state.set_auth_state(auth_state, ctx);
});
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
/// theme slide uses to pick its right-panel image.
pub fn use_vertical_tabs(&self, ctx: &AppContext) -> bool {
self.onboarding_state
.as_ref(ctx)
.ui_customization()
.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).
ctx.focus_self();
// Preload customize-slide images so they're ready when the user reaches that slide.
if FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
Self::preload_onboarding_images(ctx);
}
send_telemetry_from_ctx!(OnboardingEvent::OnboardingStarted, ctx);
send_telemetry_from_ctx!(
OnboardingEvent::SlideViewed {
slide_name: "intro".to_string(),
},
ctx
);
}
/// 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 = warpui::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,
});
for path in IntentionSlide::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 });
}
for path in ThirdPartySlide::VISUAL_IMAGE_PATHS {
asset_cache.load_asset::<ImageType>(AssetSource::Bundled { path });
}
for path in ThemePickerSlide::VISUAL_IMAGE_PATHS {
asset_cache.load_asset::<ImageType>(AssetSource::Bundled { path });
}
// Agent slide reuses customize_vertical_tabs / customize_horizontal_tabs
// which are already in CustomizeUISlide::VISUAL_IMAGE_PATHS.
}
fn handle_onboarding_completed(&mut self, ctx: &mut ViewContext<Self>) {
let settings = self.onboarding_state.as_ref(ctx).settings();
ctx.emit(AgentOnboardingEvent::OnboardingCompleted(settings));
}
fn handle_theme_picker_slide_event(
&mut self,
event: &ThemePickerSlideEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
ThemePickerSlideEvent::ThemeSelected { theme_name } => {
ctx.emit(AgentOnboardingEvent::ThemeSelected {
theme_name: theme_name.clone(),
});
}
ThemePickerSlideEvent::SyncWithOsToggled { enabled } => {
ctx.emit(AgentOnboardingEvent::SyncWithOsToggled { enabled: *enabled });
}
ThemePickerSlideEvent::PrivacySettingsRequested => {
ctx.emit(AgentOnboardingEvent::PrivacySettingsFromTerminalThemeSlideRequested);
}
}
}
}
impl Entity for AgentOnboardingView {
type Event = AgentOnboardingEvent;
}
impl View for AgentOnboardingView {
fn ui_name() -> &'static str {
"AgentOnboardingView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let mut stack = Stack::new();
if let Some(img) = theme.background_image() {
// Render the image behind everything.
stack.add_child(
Shrinkable::new(
1.,
Image::new(img.source(), CacheOption::Original)
.cover()
.finish(),
)
.finish(),
);
// Overlay the theme background so the image shows through at img.opacity.
let overlay_opacity = (100u8).saturating_sub(img.opacity);
stack.add_child(
Rect::new()
.with_background(theme.background().with_opacity(overlay_opacity))
.finish(),
);
} else {
stack.add_child(
Container::new(Empty::new().finish())
.with_background(theme.background())
.finish(),
);
}
let selected_slide = self.onboarding_state.as_ref(app).step();
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::Customize => ChildView::new(&self.customize_slide).finish(),
OnboardingStep::Agent => ChildView::new(&self.agent_slide).finish(),
OnboardingStep::ThirdParty => ChildView::new(&self.third_party_slide).finish(),
OnboardingStep::Project => ChildView::new(&self.project_slide).finish(),
};
stack.add_child(slide);
if self.skippable {
let esc = Keystroke::parse("escape").unwrap_or_default();
let close_button = self.close_button.render(
appearance,
button::Params {
content: button::Content::Label("Skip".into()),
theme: &button::themes::Naked,
options: button::Options {
size: button::Size::Small,
keystroke: Some(esc),
on_click: Some(Box::new(|ctx, _app, _pos| {
ctx.dispatch_typed_action(AgentOnboardingAction::Escape);
})),
..button::Options::default(appearance)
},
},
);
stack.add_positioned_child(
close_button,
OffsetPositioning::offset_from_parent(
vec2f(-24., 24.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopRight,
ChildAnchor::TopRight,
),
);
}
stack.finish()
}
}
impl TypedActionView for AgentOnboardingView {
type Action = AgentOnboardingAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
if matches!(action, AgentOnboardingAction::Escape) && self.skippable {
ctx.emit(AgentOnboardingEvent::OnboardingSkipped);
return;
}
let selected_slide = self.onboarding_state.as_ref(ctx).step();
match selected_slide {
OnboardingStep::Intro => self.intro_slide.update(ctx, |slide, ctx| {
dispatch_onboarding_action_to_slide(slide, *action, ctx)
}),
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::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::ThirdParty => self.third_party_slide.update(ctx, |slide, ctx| {
dispatch_onboarding_action_to_slide(slide, *action, ctx)
}),
OnboardingStep::Project => self.project_slide.update(ctx, |slide, ctx| {
dispatch_onboarding_action_to_slide(slide, *action, ctx)
}),
}
}
}
pub fn init(app: &mut AppContext) {
app.register_fixed_bindings([
FixedBinding::new(
"up",
AgentOnboardingAction::UpKey,
id!(AgentOnboardingView::ui_name()),
),
FixedBinding::new(
"down",
AgentOnboardingAction::DownKey,
id!(AgentOnboardingView::ui_name()),
),
FixedBinding::new(
"left",
AgentOnboardingAction::LeftKey,
id!(AgentOnboardingView::ui_name()),
),
FixedBinding::new(
"right",
AgentOnboardingAction::RightKey,
id!(AgentOnboardingView::ui_name()),
),
FixedBinding::new(
"tab",
AgentOnboardingAction::TabKey,
id!(AgentOnboardingView::ui_name()),
),
FixedBinding::new(
"enter",
AgentOnboardingAction::EnterKey,
id!(AgentOnboardingView::ui_name()),
),
FixedBinding::new(
"numpadenter",
AgentOnboardingAction::EnterKey,
id!(AgentOnboardingView::ui_name()),
),
FixedBinding::new(
"cmdorctrl-enter",
AgentOnboardingAction::CmdOrCtrlEnterKey,
id!(AgentOnboardingView::ui_name()),
),
FixedBinding::new(
"cmdorctrl-numpadenter",
AgentOnboardingAction::CmdOrCtrlEnterKey,
id!(AgentOnboardingView::ui_name()),
),
FixedBinding::new(
"escape",
AgentOnboardingAction::Escape,
id!(AgentOnboardingView::ui_name()),
),
]);
}
+492
View File
@@ -0,0 +1,492 @@
#![allow(dead_code)]
use ai::LLMId;
use anyhow::Result;
use onboarding::slides::OnboardingModelInfo;
use onboarding::{
AgentOnboardingEvent, AgentOnboardingView, MockTelemetryContextProvider, SelectedSettings,
};
use pathfinder_color::ColorU;
use rust_embed::RustEmbed;
use std::borrow::Cow;
use warp_core::ui::icons::Icon;
use warp_core::ui::theme::{AnsiColor, AnsiColors, Details, Fill, Image, TerminalColors};
use warp_core::ui::{appearance::Appearance, theme::WarpTheme};
use warpui::assets::asset_cache::AssetSource;
use warpui::platform;
use warpui::{
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,
};
#[derive(Clone, Copy, RustEmbed)]
#[folder = "../../app/assets"]
pub struct Assets;
pub static ASSETS: Assets = Assets;
impl AssetProvider for Assets {
fn get(&self, path: &str) -> Result<Cow<'_, [u8]>> {
<Assets as RustEmbed>::get(path)
.map(|f| f.data)
.ok_or_else(|| anyhow::anyhow!(format!("no asset exists at path {path}")))
}
}
fn main() -> Result<()> {
// Initialize logging for the onboarding binary.
warp_logging::init(warp_logging::LogConfig {
is_cli: false,
log_destination: None,
})?;
let app_builder =
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));
// Register telemetry context provider for logging telemetry events.
ctx.add_singleton_model(MockTelemetryContextProvider::new_context_provider);
ctx.add_window(AddWindowOptions::default(), |ctx| {
OnboardingMainView::new(ctx)
});
onboarding::init(ctx);
});
Ok(())
}
#[derive(Clone, Debug)]
enum OnboardingMainState {
Onboarding(ViewHandle<AgentOnboardingView>),
Finished(ViewHandle<FinishedOnboardingView>),
}
struct OnboardingMainView {
state: OnboardingMainState,
}
impl OnboardingMainView {
fn new(ctx: &mut ViewContext<Self>) -> Self {
let themes = [phenomenon(), dark_theme(), light_theme(), adeberry()];
let default_model_id = LLMId::from("auto");
let models = vec![
OnboardingModelInfo {
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
AgentOnboardingView::new(
themes.clone(),
true,
models.clone(),
default_model_id.clone(),
false,
false,
false,
None,
onboarding::OnboardingAuthState::LoggedOut,
ctx,
)
});
onboarding_view.update(ctx, |view, ctx| {
view.start_onboarding(ctx);
});
ctx.subscribe_to_view(&onboarding_view, |me, _view, event, ctx| {
me.handle_onboarding_event(event, ctx);
});
Self {
state: OnboardingMainState::Onboarding(onboarding_view),
}
}
fn handle_onboarding_event(
&mut self,
event: &AgentOnboardingEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
AgentOnboardingEvent::ThemeSelected { theme_name } => {
let theme = match theme_name.as_str() {
"Phenomenon" => phenomenon(),
"Dark" => dark_theme(),
"Light" => light_theme(),
"Adeberry" => adeberry(),
_ => return,
};
Appearance::handle(ctx).update(ctx, |appearance, ctx| {
appearance.set_theme(theme, ctx);
});
}
AgentOnboardingEvent::OnboardingCompleted(selected_settings) => {
let finished_view = ctx.add_typed_action_view(|_| {
FinishedOnboardingView::new(Some(selected_settings.clone()))
});
self.state = OnboardingMainState::Finished(finished_view);
ctx.notify();
}
AgentOnboardingEvent::OnboardingSkipped => {
let finished_view =
ctx.add_typed_action_view(|_| FinishedOnboardingView::new(None));
self.state = OnboardingMainState::Finished(finished_view);
ctx.notify();
}
AgentOnboardingEvent::SyncWithOsToggled { .. }
| AgentOnboardingEvent::UpgradeRequested
| AgentOnboardingEvent::UpgradeCopyUrlRequested
| AgentOnboardingEvent::UpgradePasteTokenFromClipboardRequested
| AgentOnboardingEvent::LoginFromWelcomeRequested
| AgentOnboardingEvent::PrivacySettingsFromTerminalThemeSlideRequested
| AgentOnboardingEvent::AppBecameActive => {
// No-op in the standalone demo binary
}
}
}
}
struct FinishedOnboardingView {
selected_settings: Option<SelectedSettings>,
}
impl FinishedOnboardingView {
fn new(selected_settings: Option<SelectedSettings>) -> Self {
Self { selected_settings }
}
}
impl Entity for FinishedOnboardingView {
type Event = ();
}
impl View for FinishedOnboardingView {
fn ui_name() -> &'static str {
"FinishedOnboardingView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let header_text = if self.selected_settings.is_some() {
"Finished Onboarding"
} else {
"Skipped Onboarding"
};
let header = appearance
.ui_builder()
.paragraph(header_text)
.with_style(UiComponentStyles {
font_size: Some(28.),
font_weight: Some(Weight::Medium),
..Default::default()
})
.build()
.finish();
let details_text = match &self.selected_settings {
Some(selected_settings) => format!("SelectedSettings: {selected_settings:?}"),
None => "SelectedSettings: (none)".to_string(),
};
let details = appearance
.ui_builder()
.paragraph(details_text)
.with_style(UiComponentStyles {
font_size: Some(14.),
font_weight: Some(Weight::Normal),
..Default::default()
})
.build()
.finish();
let theme = appearance.theme();
Container::new(
Flex::column()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(header)
.with_child(Container::new(details).with_margin_top(12.).finish())
.finish(),
)
.with_background(theme.background())
.with_uniform_padding(64.)
.finish()
}
}
impl TypedActionView for FinishedOnboardingView {
type Action = ();
}
impl Entity for OnboardingMainView {
type Event = ();
}
impl View for OnboardingMainView {
fn ui_name() -> &'static str {
"OnboardingMainView"
}
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
match &self.state {
OnboardingMainState::Onboarding(view) => ChildView::new(view).finish(),
OnboardingMainState::Finished(view) => ChildView::new(view).finish(),
}
}
fn on_focus(&mut self, focus_ctx: &warpui::FocusContext, ctx: &mut ViewContext<Self>) {
if let OnboardingMainState::Onboarding(view) = &self.state {
if focus_ctx.is_self_focused() {
ctx.focus(view);
}
}
}
}
impl TypedActionView for OnboardingMainView {
type Action = ();
}
// ---- Theme definitions copied from app::themes::default_themes (subset) ----
const DARK_MODE_NORMAL_COLORS: AnsiColors = AnsiColors::new(
AnsiColor::from_u32(0x616161FF),
AnsiColor::from_u32(0xFF8272FF),
AnsiColor::from_u32(0xB4FA72FF),
AnsiColor::from_u32(0xFEFDC2FF),
AnsiColor::from_u32(0xA5D5FEFF),
AnsiColor::from_u32(0xFF8FFDFF),
AnsiColor::from_u32(0xD0D1FEFF),
AnsiColor::from_u32(0xF1F1F1FF),
);
const DARK_MODE_BRIGHT_COLORS: AnsiColors = AnsiColors::new(
AnsiColor::from_u32(0x8E8E8EFF),
AnsiColor::from_u32(0xFFC4BDFF),
AnsiColor::from_u32(0xD6FCB9FF),
AnsiColor::from_u32(0xFEFDD5FF),
AnsiColor::from_u32(0xC1E3FEFF),
AnsiColor::from_u32(0xFFB1FEFF),
AnsiColor::from_u32(0xE5E6FEFF),
AnsiColor::from_u32(0xFEFFFFFF),
);
const LIGHT_MODE_NORMAL_COLORS: AnsiColors = AnsiColors::new(
AnsiColor::from_u32(0x212121FF),
AnsiColor::from_u32(0xC30771FF),
AnsiColor::from_u32(0x10A778FF),
AnsiColor::from_u32(0xA89C14FF),
AnsiColor::from_u32(0x008EC4FF),
AnsiColor::from_u32(0x523C79FF),
AnsiColor::from_u32(0x20A5BAFF),
AnsiColor::from_u32(0xE0E0E0FF),
);
const LIGHT_MODE_BRIGHT_COLORS: AnsiColors = AnsiColors::new(
AnsiColor::from_u32(0x212121FF),
AnsiColor::from_u32(0xFB007AFF),
AnsiColor::from_u32(0x5FD7AFFF),
AnsiColor::from_u32(0xF3E430FF),
AnsiColor::from_u32(0x20BBFCFF),
AnsiColor::from_u32(0x6855DEFF),
AnsiColor::from_u32(0x4FB8CCFF),
AnsiColor::from_u32(0xF1F1F1FF),
);
const PHENOMENON_NORMAL_COLORS: AnsiColors = AnsiColors::new(
AnsiColor::from_u32(0x121212FF),
AnsiColor::from_u32(0xD22D1EFF),
AnsiColor::from_u32(0x1CA05AFF),
AnsiColor::from_u32(0xE5A01AFF),
AnsiColor::from_u32(0x3780E9FF),
AnsiColor::from_u32(0xBF409DFF),
AnsiColor::from_u32(0x799C92FF),
AnsiColor::from_u32(0xFAF9F6FF),
);
const PHENOMENON_BRIGHT_COLORS: AnsiColors = AnsiColors::new(
AnsiColor::from_u32(0x292929FF),
AnsiColor::from_u32(0xAE756FFF),
AnsiColor::from_u32(0x789B88FF),
AnsiColor::from_u32(0xBD9F65FF),
AnsiColor::from_u32(0x6F839FFF),
AnsiColor::from_u32(0xA57899FF),
AnsiColor::from_u32(0xBFC5C3FF),
AnsiColor::from_u32(0xFFFFFFFF),
);
const ADEBERRY_NORMAL_COLORS: AnsiColors = AnsiColors::new(
AnsiColor::from_u32(0x121212FF),
AnsiColor::from_u32(0xC76156FF),
AnsiColor::from_u32(0x57C78AFF),
AnsiColor::from_u32(0xC8A35AFF),
AnsiColor::from_u32(0x5785C7FF),
AnsiColor::from_u32(0xC756A9FF),
AnsiColor::from_u32(0x57C7C3FF),
AnsiColor::from_u32(0xEEEDEBFF),
);
const ADEBERRY_BRIGHT_COLORS: AnsiColors = AnsiColors::new(
AnsiColor::from_u32(0x292929FF),
AnsiColor::from_u32(0xE3493BFF),
AnsiColor::from_u32(0x1CA05AFF),
AnsiColor::from_u32(0xE3AA3BFF),
AnsiColor::from_u32(0x3BE38AFF),
AnsiColor::from_u32(0xC8A35AFF),
AnsiColor::from_u32(0x3BE3DDFF),
AnsiColor::from_u32(0xFFFFFFFF),
);
fn dark_mode_colors() -> TerminalColors {
TerminalColors::new(DARK_MODE_NORMAL_COLORS, DARK_MODE_BRIGHT_COLORS)
}
fn light_mode_colors() -> TerminalColors {
TerminalColors::new(LIGHT_MODE_NORMAL_COLORS, LIGHT_MODE_BRIGHT_COLORS)
}
fn phenomenon_colors() -> TerminalColors {
TerminalColors::new(PHENOMENON_NORMAL_COLORS, PHENOMENON_BRIGHT_COLORS)
}
fn adeberry_colors() -> TerminalColors {
TerminalColors::new(ADEBERRY_NORMAL_COLORS, ADEBERRY_BRIGHT_COLORS)
}
fn dark_theme() -> WarpTheme {
WarpTheme::new(
Fill::Solid(ColorU::from_u32(0x000000FF)),
ColorU::from_u32(0xffffffff),
Fill::Solid(ColorU::from_u32(0x19AAD8FF)),
None,
Some(Details::Darker),
dark_mode_colors(),
None,
Some("Dark".to_string()),
)
}
fn light_theme() -> WarpTheme {
WarpTheme::new(
Fill::Solid(ColorU::white()),
ColorU::new(17, 17, 17, 0xFF),
Fill::Solid(ColorU::from_u32(0x00c2ffff)),
None,
Some(Details::Lighter),
light_mode_colors(),
None,
Some("Light".to_string()),
)
}
fn phenomenon() -> WarpTheme {
WarpTheme::new(
Fill::Solid(ColorU::from_u32(0x121212FF)),
ColorU::from_u32(0xFAF9F6FF),
Fill::Solid(ColorU::from_u32(0x2E5D9EFF)),
None,
Some(Details::Darker),
phenomenon_colors(),
Some(Image {
source: AssetSource::Bundled {
// Match app's asset layout: this image lives under app/assets/async.
path: "async/jpg/phenomenon_bg.jpg",
},
opacity: 100,
}),
Some("Phenomenon".to_string()),
)
}
fn adeberry() -> WarpTheme {
WarpTheme::new(
Fill::Solid(ColorU::from_u32(0x1D2022FF)),
ColorU::from_u32(0xE4EEF5FF),
Fill::Solid(ColorU::from_u32(0x6C96B4FF)),
None,
Some(Details::Darker),
adeberry_colors(),
None,
Some("Adeberry".to_string()),
)
}
fn build_appearance(theme: WarpTheme, ctx: &mut AppContext) -> Appearance {
let ui_font_family =
load_default_ui_font_family(ctx).expect("unable to load default ui font family");
Appearance::new(
theme,
ui_font_family,
13.0,
Weight::Normal,
ui_font_family,
1.2,
ui_font_family,
ui_font_family,
)
}
fn load_default_ui_font_family(ctx: &mut AppContext) -> anyhow::Result<FamilyId> {
Cache::handle(ctx).update(ctx, |font_cache, _| {
// On Windows, default to use Segoe UI as the UI font.
#[cfg(windows)]
if let Ok(font_family_id) = font_cache.load_system_font("Segoe UI") {
return Ok(font_family_id);
}
font_cache.load_family_from_bytes(
"Roboto",
vec![
ASSETS
.get("bundled/fonts/roboto/Roboto-Italic.ttf")?
.to_vec(),
ASSETS.get("bundled/fonts/roboto/Roboto-Bold.ttf")?.to_vec(),
ASSETS
.get("bundled/fonts/roboto/Roboto-Regular.ttf")?
.to_vec(),
ASSETS
.get("bundled/fonts/roboto/Roboto-Medium.ttf")?
.to_vec(),
ASSETS
.get("bundled/fonts/roboto/RobotoFlex-Semibold.ttf")?
.to_vec(),
ASSETS
.get("bundled/fonts/roboto/Roboto-BoldItalic.ttf")?
.to_vec(),
],
)
})
}
+9
View File
@@ -0,0 +1,9 @@
mod model;
mod view;
pub use model::{FinalState, OnboardingQuery};
pub use view::{OnboardingCalloutView, OnboardingCalloutViewEvent, OnboardingKeybindings};
pub fn init(app: &mut warpui::AppContext) {
view::init(app);
}
+487
View File
@@ -0,0 +1,487 @@
use crate::telemetry::OnboardingEvent;
use crate::OnboardingIntention;
use warp_core::send_telemetry_from_ctx;
use warpui::{Entity, ModelContext};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FinalState {
/// User submitted the agent query (legacy flow)
Submit,
/// User skipped the callout (legacy flow or skip initialization)
Skip,
/// User finished the callout without submitting
Finish,
/// User chose to initialize the project (AgentModality with project)
Initialize,
/// User chose to go back to terminal (AgentModality without project)
BackToTerminal,
}
impl std::fmt::Display for FinalState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FinalState::Submit => write!(f, "submitted"),
FinalState::Skip => write!(f, "skipped"),
FinalState::Finish => write!(f, "finished"),
FinalState::Initialize => write!(f, "initialize"),
FinalState::BackToTerminal => write!(f, "back_to_terminal"),
}
}
}
/// Prompt information for the onboarding callout
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum OnboardingQuery {
/// A terminal command that should be executed in shell mode
TerminalCommand(String),
/// An agent prompt that should be executed in agent mode
AgentPrompt(String),
/// No prompt (empty state)
None,
}
#[derive(Clone, Copy, Debug)]
pub(super) enum OnboardingCalloutModelEvent {
StateUpdated,
Completed(FinalState),
EnterAgentModality,
/// Emitted when the user toggles the natural language detection checkbox.
NaturalLanguageDetectionToggled(bool),
}
/// State for the UniversalInput onboarding flow (non-AgentModality).
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(super) enum UniversalInputCalloutState {
#[default]
Off,
MeetInput,
TalkToAgent,
Complete(FinalState),
}
/// State for the AgentModality onboarding flow.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
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,
/// Terminal state
Complete(FinalState),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum OnboardingCalloutState {
UniversalInput(UniversalInputCalloutState),
AgentModality(AgentModalityCalloutState),
}
pub(super) struct OnboardingCalloutModel {
state: OnboardingCalloutState,
intention: OnboardingIntention,
has_project: bool,
/// The initial value of natural language detection when onboarding started.
/// Used to determine which callout variant to show.
initial_natural_language_detection_enabled: bool,
/// The current value of natural language detection (may change via checkbox toggle).
natural_language_detection_enabled: bool,
}
impl OnboardingCalloutModel {
/// Create a new model for UniversalInput onboarding flow.
pub fn new_universal_input(
has_project: bool,
initial_natural_language_detection_enabled: bool,
) -> Self {
Self {
state: OnboardingCalloutState::UniversalInput(UniversalInputCalloutState::default()),
intention: OnboardingIntention::AgentDrivenDevelopment,
has_project,
initial_natural_language_detection_enabled,
natural_language_detection_enabled: initial_natural_language_detection_enabled,
}
}
/// Create a new model for AgentModality onboarding flow.
pub fn new_agent_modality(
has_project: bool,
intention: OnboardingIntention,
initial_natural_language_detection_enabled: bool,
) -> Self {
Self {
state: OnboardingCalloutState::AgentModality(AgentModalityCalloutState::default()),
intention,
has_project,
initial_natural_language_detection_enabled,
natural_language_detection_enabled: initial_natural_language_detection_enabled,
}
}
pub fn has_project(&self) -> bool {
self.has_project
}
pub fn intention(&self) -> OnboardingIntention {
self.intention
}
pub fn initial_natural_language_detection_enabled(&self) -> bool {
self.initial_natural_language_detection_enabled
}
pub fn natural_language_detection_enabled(&self) -> bool {
self.natural_language_detection_enabled
}
pub fn toggle_natural_language_detection(&mut self, ctx: &mut ModelContext<Self>) {
self.natural_language_detection_enabled = !self.natural_language_detection_enabled;
ctx.emit(
OnboardingCalloutModelEvent::NaturalLanguageDetectionToggled(
self.natural_language_detection_enabled,
),
);
ctx.emit(OnboardingCalloutModelEvent::StateUpdated);
ctx.notify();
}
pub fn next(&mut self, ctx: &mut ModelContext<Self>) {
send_telemetry_from_ctx!(OnboardingEvent::CalloutNext, ctx);
match &self.state {
OnboardingCalloutState::UniversalInput(universal_input_state) => {
self.next_universal_input(*universal_input_state, ctx);
}
OnboardingCalloutState::AgentModality(modality_state) => {
self.next_agent_modality(*modality_state, ctx);
}
}
}
fn next_universal_input(
&mut self,
state: UniversalInputCalloutState,
ctx: &mut ModelContext<Self>,
) {
let next_state = match state {
UniversalInputCalloutState::Off => Some(UniversalInputCalloutState::MeetInput),
UniversalInputCalloutState::MeetInput => Some(UniversalInputCalloutState::TalkToAgent),
UniversalInputCalloutState::TalkToAgent => {
Some(UniversalInputCalloutState::Complete(FinalState::Submit))
}
UniversalInputCalloutState::Complete(_) => None,
};
if let Some(next_state) = next_state {
self.set_state(OnboardingCalloutState::UniversalInput(next_state), ctx);
}
}
fn next_agent_modality(
&mut self,
state: AgentModalityCalloutState,
ctx: &mut ModelContext<Self>,
) {
let (next_state, emit_enter_agent_modality) = match state {
AgentModalityCalloutState::Off => {
(Some(AgentModalityCalloutState::MeetTerminalInput), false)
}
AgentModalityCalloutState::MeetTerminalInput => (
Some(AgentModalityCalloutState::NaturalLanguageSupport),
false,
),
AgentModalityCalloutState::NaturalLanguageSupport => {
// For Terminal intention, finish here
// For Agent intention, continue to IntroducingAgentExperience
match self.intention {
OnboardingIntention::Terminal => (
Some(AgentModalityCalloutState::Complete(FinalState::Finish)),
false,
),
OnboardingIntention::AgentDrivenDevelopment => {
// Signal to enter agent modality when showing the agent experience slide
(
Some(AgentModalityCalloutState::IntroducingAgentExperience),
true,
)
}
}
}
AgentModalityCalloutState::IntroducingAgentExperience => {
(Some(AgentModalityCalloutState::UpdatedAgentInput), false)
}
AgentModalityCalloutState::UpdatedAgentInput => {
// For Agent with project: Initialize
// For Agent without project: Finish
let final_state = if self.has_project {
FinalState::Initialize
} else {
FinalState::Finish
};
(
Some(AgentModalityCalloutState::Complete(final_state)),
false,
)
}
AgentModalityCalloutState::Complete(_) => (None, false),
};
if let Some(next_state) = next_state {
self.set_state(OnboardingCalloutState::AgentModality(next_state), ctx);
}
if emit_enter_agent_modality {
ctx.emit(OnboardingCalloutModelEvent::EnterAgentModality);
}
}
pub fn skip(&mut self, ctx: &mut ModelContext<Self>) {
match &self.state {
OnboardingCalloutState::UniversalInput(UniversalInputCalloutState::TalkToAgent) => {
self.set_state(
OnboardingCalloutState::UniversalInput(UniversalInputCalloutState::Complete(
FinalState::Skip,
)),
ctx,
);
}
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::UpdatedAgentInput) => {
// Skip initialization
self.set_state(
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::Complete(
FinalState::Skip,
)),
ctx,
);
}
_ => log::error!(
"Skip action called in an unskippable state: {:?}",
self.state
),
}
}
pub fn finish(&mut self, ctx: &mut ModelContext<Self>) {
match &self.state {
OnboardingCalloutState::UniversalInput(UniversalInputCalloutState::TalkToAgent) => {
self.set_state(
OnboardingCalloutState::UniversalInput(UniversalInputCalloutState::Complete(
FinalState::Finish,
)),
ctx,
);
}
OnboardingCalloutState::AgentModality(
AgentModalityCalloutState::NaturalLanguageSupport,
) => {
// Terminal intention finishes here
self.set_state(
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::Complete(
FinalState::Finish,
)),
ctx,
);
}
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::UpdatedAgentInput) => {
// Agent without project finishes here
self.set_state(
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::Complete(
FinalState::Finish,
)),
ctx,
);
}
_ => log::error!("Finish action called in an invalid state: {:?}", self.state),
}
}
/// Handle "Back to terminal" action (ESC in UpdatedAgentInput without project)
pub fn back_to_terminal(&mut self, ctx: &mut ModelContext<Self>) {
match &self.state {
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::UpdatedAgentInput) => {
self.set_state(
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::Complete(
FinalState::BackToTerminal,
)),
ctx,
);
}
_ => log::error!(
"BackToTerminal action called in an invalid state: {:?}",
self.state
),
}
}
pub fn is_onboarding_active(&self) -> bool {
match &self.state {
OnboardingCalloutState::UniversalInput(state) => !matches!(
state,
UniversalInputCalloutState::Off | UniversalInputCalloutState::Complete(_)
),
OnboardingCalloutState::AgentModality(state) => !matches!(
state,
AgentModalityCalloutState::Off | AgentModalityCalloutState::Complete(_)
),
}
}
pub fn state(&self) -> OnboardingCalloutState {
self.state
}
fn send_callout_displayed_telemetry(
new_state: OnboardingCalloutState,
ctx: &mut ModelContext<Self>,
) {
let callout_name = match new_state {
OnboardingCalloutState::UniversalInput(UniversalInputCalloutState::MeetInput) => {
Some("meet_input")
}
OnboardingCalloutState::UniversalInput(UniversalInputCalloutState::TalkToAgent) => {
Some("talk_to_agent")
}
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::MeetTerminalInput) => {
Some("meet_terminal_input")
}
OnboardingCalloutState::AgentModality(
AgentModalityCalloutState::NaturalLanguageSupport,
) => Some("natural_language_support"),
OnboardingCalloutState::AgentModality(
AgentModalityCalloutState::IntroducingAgentExperience,
) => Some("introducing_agent_experience"),
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::UpdatedAgentInput) => {
Some("updated_agent_input")
}
_ => None,
};
if let Some(callout) = callout_name {
send_telemetry_from_ctx!(
OnboardingEvent::CalloutDisplayed {
callout: callout.to_string(),
},
ctx
);
}
}
fn set_state(&mut self, new_state: OnboardingCalloutState, ctx: &mut ModelContext<Self>) {
if self.state != new_state {
self.state = new_state;
Self::send_callout_displayed_telemetry(new_state, ctx);
ctx.emit(OnboardingCalloutModelEvent::StateUpdated);
// Check for completion
let final_state = match new_state {
OnboardingCalloutState::UniversalInput(UniversalInputCalloutState::Complete(
fs,
)) => Some(fs),
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::Complete(fs)) => {
Some(fs)
}
_ => None,
};
if let Some(final_state) = final_state {
send_telemetry_from_ctx!(
OnboardingEvent::CalloutCompleted {
completion_type: final_state.to_string(),
},
ctx
);
ctx.emit(OnboardingCalloutModelEvent::Completed(final_state));
}
}
}
/// Returns a prompt string to populate a command based on current state
pub fn prompt_string(&self) -> String {
match self.prompt() {
OnboardingQuery::TerminalCommand(text) | OnboardingQuery::AgentPrompt(text) => text,
OnboardingQuery::None => String::new(),
}
}
/// Returns the prompt information including type for the current state
pub fn prompt(&self) -> OnboardingQuery {
match &self.state {
OnboardingCalloutState::UniversalInput(state) => {
self.prompt_for_universal_input(*state)
}
OnboardingCalloutState::AgentModality(state) => self.prompt_for_agent_modality(*state),
}
}
fn prompt_for_universal_input(&self, state: UniversalInputCalloutState) -> OnboardingQuery {
match state {
UniversalInputCalloutState::Off
| UniversalInputCalloutState::Complete(FinalState::Skip)
| UniversalInputCalloutState::Complete(FinalState::Finish) => OnboardingQuery::None,
UniversalInputCalloutState::MeetInput => {
OnboardingQuery::TerminalCommand("git status".to_string())
}
UniversalInputCalloutState::TalkToAgent
| UniversalInputCalloutState::Complete(FinalState::Submit) => {
OnboardingQuery::AgentPrompt(
"What tests exist in this repo, how are they structured, and what do they cover?"
.to_string(),
)
}
UniversalInputCalloutState::Complete(_) => OnboardingQuery::None,
}
}
fn prompt_for_agent_modality(&self, state: AgentModalityCalloutState) -> OnboardingQuery {
match state {
AgentModalityCalloutState::Off => OnboardingQuery::None,
AgentModalityCalloutState::MeetTerminalInput => {
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 => {
if self.has_project {
OnboardingQuery::AgentPrompt("/init".to_string())
} else {
OnboardingQuery::AgentPrompt("Tell the agent what to build...".to_string())
}
}
// All completion states should return None so the input gets cleared
AgentModalityCalloutState::Complete(_) => OnboardingQuery::None,
}
}
pub fn start_onboarding(&mut self, ctx: &mut ModelContext<Self>) {
log::info!(
"start_onboarding called with current state: {:?}",
self.state
);
match &self.state {
OnboardingCalloutState::UniversalInput(_) => {
log::info!("Transitioning to UniversalInput::MeetInput");
self.set_state(
OnboardingCalloutState::UniversalInput(UniversalInputCalloutState::MeetInput),
ctx,
);
}
OnboardingCalloutState::AgentModality(_) => {
log::info!("Transitioning to AgentModality::MeetTerminalInput");
self.set_state(
OnboardingCalloutState::AgentModality(
AgentModalityCalloutState::MeetTerminalInput,
),
ctx,
);
}
}
}
}
impl Entity for OnboardingCalloutModel {
type Event = OnboardingCalloutModelEvent;
}
+519
View File
@@ -0,0 +1,519 @@
use ui_components::Component;
use warp_core::ui::appearance::Appearance;
use warpui::{
elements::Empty,
keymap::{macros::*, FixedBinding, Keystroke},
AppContext, Element, Entity, EventContext, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext,
};
/// Display strings for keybindings shown in the onboarding callout.
#[derive(Clone, Debug)]
pub struct OnboardingKeybindings {
/// Display string for toggling between agent/terminal mode (e.g., "⌘I")
pub toggle_input_mode: String,
/// Display string for submitting to local agent (e.g., "⌘⏎")
pub submit_to_local_agent: String,
/// Display string for submitting to cloud agent (e.g., "⌘⌥⏎")
pub submit_to_cloud_agent: String,
}
use crate::{
callout::model::{
AgentModalityCalloutState, FinalState, OnboardingCalloutModel, OnboardingCalloutModelEvent,
OnboardingCalloutState, OnboardingQuery, UniversalInputCalloutState,
},
components::onboarding_callout::{self, Button, StepStatus},
OnboardingIntention,
};
/// Options for rendering a callout.
struct CalloutOptions {
title: &'static str,
/// Pre-built text with keybindings already embedded
text: String,
step: StepStatus,
right_button: ButtonOptions,
/// Optional left button (e.g., "Skip", "Back to terminal")
left_button: Option<ButtonOptions>,
/// Optional checkbox for natural language detection
checkbox: Option<CheckboxOptions>,
}
struct ButtonOptions {
text: &'static str,
action: OnboardingCalloutViewAction,
keystroke: Option<Keystroke>,
}
struct CheckboxOptions {
label: &'static str,
checked: bool,
}
fn get_universal_input_callout_options(
state: UniversalInputCalloutState,
has_project: bool,
keybindings: &OnboardingKeybindings,
) -> Option<CalloutOptions> {
match state {
UniversalInputCalloutState::MeetInput => Some(CalloutOptions {
title: "Meet the Warp input",
text: format!(
"Your terminal input accepts both terminal commands and agent prompts and automatically detects which you're using. Use {} to lock the input to Agent mode (natural language) or Terminal mode (commands).",
keybindings.toggle_input_mode
),
step: StepStatus::new(0, 2),
left_button: None,
right_button: ButtonOptions {
text: "Next",
action: OnboardingCalloutViewAction::NextClicked,
keystroke: Some(Keystroke::parse("enter").unwrap_or_default()),
},
checkbox: None,
}),
UniversalInputCalloutState::TalkToAgent => Some(CalloutOptions {
title: "Talk to the agent",
text: "You can type in natural language to engage the agent. Submit the query below to start: What tests exist in this repo, how are they structured, and what do they cover?".to_string(),
step: StepStatus::new(1, 2),
left_button: if has_project {
Some(ButtonOptions {
text: "Skip",
action: OnboardingCalloutViewAction::SkipClicked,
keystroke: Some(Keystroke::parse("delete").unwrap_or_default()),
})
} else {
None
},
right_button: ButtonOptions {
text: if has_project { "Submit" } else { "Finish" },
action: OnboardingCalloutViewAction::NextClicked,
keystroke: Some(Keystroke::parse("enter").unwrap_or_default()),
},
checkbox: None,
}),
UniversalInputCalloutState::Off | UniversalInputCalloutState::Complete(_) => None,
}
}
fn get_agent_modality_callout_options(
state: AgentModalityCalloutState,
intention: OnboardingIntention,
has_project: bool,
initial_natural_language_detection_enabled: bool,
natural_language_detection_enabled: bool,
keybindings: &OnboardingKeybindings,
) -> Option<CalloutOptions> {
let total_steps = match intention {
OnboardingIntention::Terminal => 2,
OnboardingIntention::AgentDrivenDevelopment => 4,
};
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 => {
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",
text: format!(
"You can always override any auto-detection using {}.",
keybindings.toggle_input_mode
),
step: StepStatus::new(1, total_steps),
left_button: None,
right_button: ButtonOptions {
text: if is_final_step { "Finish" } else { "Next" },
action: OnboardingCalloutViewAction::NextClicked,
keystroke: Some(Keystroke::parse("enter").unwrap_or_default()),
},
checkbox: None,
})
} else {
// NL detection was disabled - show full explanation with checkbox to enable
Some(CalloutOptions {
title: "Natural language support",
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 {}.",
keybindings.toggle_input_mode
),
step: StepStatus::new(1, total_steps),
left_button: None,
right_button: ButtonOptions {
text: if is_final_step { "Finish" } else { "Next" },
action: OnboardingCalloutViewAction::NextClicked,
keystroke: Some(Keystroke::parse("enter").unwrap_or_default()),
},
checkbox: Some(CheckboxOptions {
label: "Enable Natural Language Detection",
checked: natural_language_detection_enabled,
}),
})
}
}
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 => {
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),
left_button: Some(ButtonOptions {
text: "Skip initialization",
action: OnboardingCalloutViewAction::SkipClicked,
keystroke: Some(Keystroke::parse("delete").unwrap_or_default()),
}),
right_button: ButtonOptions {
text: "Initialize",
action: OnboardingCalloutViewAction::NextClicked,
keystroke: Some(Keystroke::parse("enter").unwrap_or_default()),
},
checkbox: None,
})
} 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),
left_button: Some(ButtonOptions {
text: "Back to terminal",
action: OnboardingCalloutViewAction::BackToTerminalClicked,
keystroke: Some(Keystroke::parse("escape").unwrap_or_default()),
}),
right_button: ButtonOptions {
text: "Finish",
action: OnboardingCalloutViewAction::NextClicked,
keystroke: Some(Keystroke::parse("enter").unwrap_or_default()),
},
checkbox: None,
})
}
}
AgentModalityCalloutState::Off | AgentModalityCalloutState::Complete(_) => None,
}
}
#[derive(Clone, Debug)]
pub enum OnboardingCalloutViewAction {
NextClicked,
SkipClicked,
BackToTerminalClicked,
ToggleCheckbox,
}
pub fn init(app: &mut AppContext) {
app.register_fixed_bindings([
FixedBinding::new(
"enter",
OnboardingCalloutViewAction::NextClicked,
id!(OnboardingCalloutView::ui_name()),
),
FixedBinding::new(
"numpadenter",
OnboardingCalloutViewAction::NextClicked,
id!(OnboardingCalloutView::ui_name()),
),
FixedBinding::new(
"backspace",
OnboardingCalloutViewAction::SkipClicked,
id!(OnboardingCalloutView::ui_name()),
),
FixedBinding::new(
"escape",
OnboardingCalloutViewAction::BackToTerminalClicked,
id!(OnboardingCalloutView::ui_name()),
),
]);
}
#[derive(Clone, Debug)]
pub enum OnboardingCalloutViewEvent {
StateUpdated,
Completed {
final_state: FinalState,
},
/// Signals that the terminal should enter agent modality (agent view).
EnterAgentModality,
/// Emitted when the user toggles the natural language detection checkbox.
NaturalLanguageDetectionToggled(bool),
}
/// A view that renders the onboarding callout UI component based on the current model state
pub struct OnboardingCalloutView {
/// Reference to the model that manages onboarding state
model: ModelHandle<OnboardingCalloutModel>,
/// The UI component that renders the actual callout
callout_component: onboarding_callout::OnboardingCallout,
/// Display strings for keybindings shown in the callout
keybindings: OnboardingKeybindings,
}
impl OnboardingCalloutView {
/// Create a new view for the UniversalInput onboarding flow.
pub fn new_universal_input(
has_project: bool,
initial_natural_language_detection_enabled: bool,
keybindings: OnboardingKeybindings,
ctx: &mut ViewContext<Self>,
) -> Self {
let model = ctx.add_model(|_ctx| {
OnboardingCalloutModel::new_universal_input(
has_project,
initial_natural_language_detection_enabled,
)
});
Self::with_model(model, keybindings, ctx)
}
/// Create a new view for the AgentModality onboarding flow.
pub fn new_agent_modality(
has_project: bool,
intention: OnboardingIntention,
initial_natural_language_detection_enabled: bool,
keybindings: OnboardingKeybindings,
ctx: &mut ViewContext<Self>,
) -> Self {
let model = ctx.add_model(|_ctx| {
OnboardingCalloutModel::new_agent_modality(
has_project,
intention,
initial_natural_language_detection_enabled,
)
});
Self::with_model(model, keybindings, ctx)
}
fn with_model(
model: ModelHandle<OnboardingCalloutModel>,
keybindings: OnboardingKeybindings,
ctx: &mut ViewContext<Self>,
) -> Self {
// Re-emit model updates as view events so parents can subscribe to the view.
ctx.subscribe_to_model(&model, |_me, _model, event, ctx| match event {
OnboardingCalloutModelEvent::StateUpdated => {
ctx.emit(OnboardingCalloutViewEvent::StateUpdated);
ctx.notify();
}
OnboardingCalloutModelEvent::Completed(final_state) => {
ctx.emit(OnboardingCalloutViewEvent::Completed {
final_state: *final_state,
});
ctx.notify();
}
OnboardingCalloutModelEvent::EnterAgentModality => {
ctx.emit(OnboardingCalloutViewEvent::EnterAgentModality);
ctx.notify();
}
OnboardingCalloutModelEvent::NaturalLanguageDetectionToggled(enabled) => {
ctx.emit(OnboardingCalloutViewEvent::NaturalLanguageDetectionToggled(
*enabled,
));
ctx.notify();
}
});
Self {
model,
callout_component: onboarding_callout::OnboardingCallout::default(),
keybindings,
}
}
pub fn has_project(&self, app: &AppContext) -> bool {
self.model.as_ref(app).has_project()
}
pub fn start_onboarding(&mut self, ctx: &mut ViewContext<Self>) {
self.model.update(ctx, |model, ctx| {
model.start_onboarding(ctx);
});
ctx.notify();
}
pub fn is_onboarding_active(&self, app: &AppContext) -> bool {
self.model.as_ref(app).is_onboarding_active()
}
pub fn prompt_string(&self, app: &AppContext) -> String {
self.model.as_ref(app).prompt_string()
}
pub fn prompt(&self, app: &AppContext) -> OnboardingQuery {
self.model.as_ref(app).prompt()
}
/// Returns true if the callout should be positioned above the zero state.
/// For UpdatedAgentInput 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)
)
}
fn get_callout_options(&self, app: &AppContext) -> Option<CalloutOptions> {
let model = self.model.as_ref(app);
match model.state() {
OnboardingCalloutState::UniversalInput(state) => {
get_universal_input_callout_options(state, model.has_project(), &self.keybindings)
}
OnboardingCalloutState::AgentModality(state) => get_agent_modality_callout_options(
state,
model.intention(),
model.has_project(),
model.initial_natural_language_detection_enabled(),
model.natural_language_detection_enabled(),
&self.keybindings,
),
}
}
}
impl Entity for OnboardingCalloutView {
type Event = OnboardingCalloutViewEvent;
}
impl View for OnboardingCalloutView {
fn ui_name() -> &'static str {
"OnboardingCalloutView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let model = self.model.as_ref(app);
// Check if onboarding is active and render appropriate callout based on state
if !model.is_onboarding_active() {
return Empty::new().finish();
}
let Some(options) = self.get_callout_options(app) else {
log::warn!(
"Onboarding callout view: onboarding is active but state has no callout options: {:?}",
model.state()
);
return Empty::new().finish();
};
let right_button = Button {
text: options.right_button.text.into(),
keystroke: options.right_button.keystroke,
handler: Box::new(move |ctx: &mut EventContext, _app_ctx: &AppContext, _pos| {
ctx.dispatch_typed_action(options.right_button.action.clone());
}),
};
let left_button = options.left_button.map(|left_opts| Button {
text: left_opts.text.into(),
keystroke: left_opts.keystroke,
handler: Box::new(move |ctx: &mut EventContext, _app_ctx: &AppContext, _pos| {
ctx.dispatch_typed_action(left_opts.action.clone());
}),
});
let checkbox = options
.checkbox
.map(|checkbox_opts| onboarding_callout::Checkbox {
label: checkbox_opts.label.into(),
checked: checkbox_opts.checked,
handler: Box::new(|ctx: &mut EventContext, _app_ctx: &AppContext, _pos| {
ctx.dispatch_typed_action(OnboardingCalloutViewAction::ToggleCheckbox);
}),
});
// Render the callout component with data from the model state
self.callout_component.render(
appearance,
onboarding_callout::Params {
title: options.title.to_string().into(),
text: options.text.into(),
step: options.step,
right_button,
options: onboarding_callout::Options {
left_button,
checkbox,
},
},
)
}
}
impl TypedActionView for OnboardingCalloutView {
type Action = OnboardingCalloutViewAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
OnboardingCalloutViewAction::NextClicked => {
self.model.update(ctx, |model, ctx| {
// Handle special cases for UniversalInput flow
if let OnboardingCalloutState::UniversalInput(
UniversalInputCalloutState::TalkToAgent,
) = model.state()
{
if !model.has_project() {
model.finish(ctx);
return;
}
}
model.next(ctx);
});
ctx.notify();
}
OnboardingCalloutViewAction::SkipClicked => {
self.model.update(ctx, |model, ctx| {
model.skip(ctx);
});
ctx.notify();
}
OnboardingCalloutViewAction::BackToTerminalClicked => {
self.model.update(ctx, |model, ctx| {
model.back_to_terminal(ctx);
});
ctx.notify();
}
OnboardingCalloutViewAction::ToggleCheckbox => {
self.model.update(ctx, |model, ctx| {
model.toggle_natural_language_detection(ctx);
});
ctx.notify();
}
}
}
}
impl SingletonEntity for OnboardingCalloutView {}
+1
View File
@@ -0,0 +1 @@
pub mod onboarding_callout;
@@ -0,0 +1,400 @@
use std::borrow::Cow;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use ui_components::{
button, button::Button as ButtonComponent, Component, MouseEventHandler, Options as _,
};
use warp_core::ui::{
appearance::Appearance,
color::{coloru_with_opacity, contrast::relative_luminance},
theme::{phenomenon::PhenomenonStyle, Fill},
};
use warpui::{
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},
};
const CALLOUT_WIDTH: f32 = 480.;
const CALLOUT_BORDER_WIDTH: f32 = 1.;
const CALLOUT_CORNER_RADIUS: f32 = 8.;
const CALLOUT_PADDING: f32 = 16.;
struct PhenomenonPrimaryButtonTheme;
struct PhenomenonSecondaryButtonTheme;
impl button::Theme for PhenomenonPrimaryButtonTheme {
fn background(&self, button_state: button::State, _appearance: &Appearance) -> Option<Fill> {
let hovered = matches!(
button_state,
button::State::Hovered | button::State::Pressed
);
Some(PhenomenonStyle::primary_button_background(hovered))
}
fn text_color(&self, _background: Option<Fill>, _appearance: &Appearance) -> ColorU {
PhenomenonStyle::primary_button_text()
}
fn keyboard_shortcut_border(&self, text_color: ColorU, _: &Appearance) -> Option<ColorU> {
Some(coloru_with_opacity(text_color, 60))
}
}
impl button::Theme for PhenomenonSecondaryButtonTheme {
fn background(&self, button_state: button::State, _appearance: &Appearance) -> Option<Fill> {
match button_state {
button::State::Default => None,
button::State::Hovered | button::State::Pressed => {
Some(PhenomenonStyle::segmented_control_background())
}
}
}
fn text_color(&self, _background: Option<Fill>, _appearance: &Appearance) -> ColorU {
PhenomenonStyle::foreground()
}
fn border(&self, _appearance: &Appearance) -> Option<ColorU> {
Some(PhenomenonStyle::subtle_border())
}
fn keyboard_shortcut_border(&self, text_color: ColorU, _: &Appearance) -> Option<ColorU> {
Some(coloru_with_opacity(text_color, 60))
}
}
#[derive(Debug, Clone, Copy)]
pub struct StepStatus {
pub current_step: u8,
pub total_steps: u8,
}
impl StepStatus {
pub fn new(current_step: u8, total_steps: u8) -> Self {
Self {
current_step,
total_steps,
}
}
}
pub struct Button {
pub text: Cow<'static, str>,
pub keystroke: Option<Keystroke>,
pub handler: MouseEventHandler,
}
impl Button {
pub fn next(handler: MouseEventHandler) -> Self {
Self {
text: Cow::Borrowed("Next"),
keystroke: Some(Keystroke {
key: "enter".into(),
..Default::default()
}),
handler,
}
}
}
/// A checkbox with a label and click handler.
pub struct Checkbox {
pub label: Cow<'static, str>,
pub checked: bool,
pub handler: MouseEventHandler,
}
#[derive(Default)]
pub struct OnboardingCallout {
right_button: ButtonComponent,
left_button: ButtonComponent,
checkbox_mouse_state: MouseStateHandle,
}
pub struct Params {
/// The title of the callout.
pub title: Cow<'static, str>,
/// The body text of the callout.
pub text: Cow<'static, str>,
/// Current step and total steps.
pub step: StepStatus,
pub right_button: Button,
/// Optional configuration.
pub options: Options,
}
impl ui_components::Params for Params {
type Options<'a> = Options;
}
pub struct Options {
/// Optional left button, typically "Skip".
pub left_button: Option<Button>,
/// Optional checkbox for toggling settings.
pub checkbox: Option<Checkbox>,
}
impl ui_components::Options for Options {
fn default(_appearance: &Appearance) -> Self {
Self {
left_button: None,
checkbox: None,
}
}
}
impl Component for OnboardingCallout {
type Params<'a> = Params;
fn render<'a>(&self, appearance: &Appearance, params: Self::Params<'a>) -> Box<dyn Element> {
let Params {
title,
text,
step,
right_button,
options,
} = params;
let header = self.render_header(appearance, &title);
let body = self.render_body(appearance, &text);
// Take checkbox out before passing options to render_actions
let mut options = options;
let checkbox = options
.checkbox
.take()
.map(|cb| self.render_checkbox(appearance, cb));
let actions = self.render_actions(step, right_button, options, appearance);
let mut content = Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_spacing(12.);
content.add_child(header);
content.add_child(body);
if let Some(checkbox_element) = checkbox {
content.add_child(checkbox_element);
}
content.add_child(actions);
let content = content.finish();
let background = PhenomenonStyle::tinted_surface();
let border_color = Fill::Solid(PhenomenonStyle::surface_border());
// Use lighter shadow on dark themes, darker shadow on light themes
let background_luminance = relative_luminance(appearance.theme().background().into_solid());
let is_light_theme = background_luminance > 0.2;
let shadow_opacity = if is_light_theme { 20 } else { 35 };
let shadow = DropShadow {
color: coloru_with_opacity(ColorU::black(), shadow_opacity),
offset: vec2f(0., 10.),
blur_radius: 20.,
spread_radius: 0.,
};
ConstrainedBox::new(
Container::new(content)
.with_uniform_padding(CALLOUT_PADDING)
.with_background(background)
.with_border(Border::all(CALLOUT_BORDER_WIDTH).with_border_fill(border_color))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
CALLOUT_CORNER_RADIUS,
)))
.with_drop_shadow(shadow)
.finish(),
)
.with_width(CALLOUT_WIDTH)
.finish()
}
}
impl OnboardingCallout {
fn render_header(&self, appearance: &Appearance, title: &str) -> Box<dyn Element> {
appearance
.ui_builder()
.paragraph(title.to_string())
.with_style(UiComponentStyles {
font_color: Some(PhenomenonStyle::foreground()),
font_size: Some(16.),
font_weight: Some(Weight::Bold),
..Default::default()
})
.build()
.finish()
}
fn render_body(&self, appearance: &Appearance, text: &str) -> Box<dyn Element> {
appearance
.ui_builder()
.paragraph(text.to_string())
.with_style(UiComponentStyles {
font_color: Some(PhenomenonStyle::body_text()),
font_size: Some(13.),
..Default::default()
})
.build()
.finish()
}
fn render_checkbox(&self, appearance: &Appearance, checkbox: Checkbox) -> Box<dyn Element> {
let checkbox_size = Some(12.);
let corner_radius = CornerRadius::with_all(Radius::Pixels(2.));
let foreground_color = PhenomenonStyle::foreground();
let subtle_border = Fill::Solid(PhenomenonStyle::subtle_border());
let checkbox_element = WarpCheckbox::new(
self.checkbox_mouse_state.clone(),
UiComponentStyles {
font_size: checkbox_size,
border_color: Some(Fill::Solid(foreground_color).into()),
font_color: Some(foreground_color),
border_width: Some(1.),
border_radius: Some(corner_radius),
..Default::default()
},
None,
Some(UiComponentStyles {
font_size: checkbox_size,
background: Some(Fill::Solid(foreground_color).into()),
border_color: Some(Fill::Solid(foreground_color).into()),
font_color: Some(PhenomenonStyle::background()),
border_radius: Some(corner_radius),
..Default::default()
}),
Some(UiComponentStyles {
font_size: checkbox_size,
border_color: Some(subtle_border.into()),
font_color: Some(PhenomenonStyle::subtle_border()),
border_width: Some(1.),
border_radius: Some(corner_radius),
..Default::default()
}),
)
.check(checkbox.checked)
.build()
.on_click(checkbox.handler)
.finish();
let label = appearance
.ui_builder()
.paragraph(checkbox.label.to_string())
.with_style(UiComponentStyles {
font_color: Some(PhenomenonStyle::label_text()),
font_size: Some(12.),
..Default::default()
})
.build()
.finish();
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(8.)
.with_child(checkbox_element)
.with_child(label)
.finish()
}
fn render_status_dots(&self, step: StepStatus) -> Box<dyn Element> {
const DOT_SIZE: f32 = 8.;
const DOT_SPACING: f32 = 4.;
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(DOT_SPACING);
for i in 0..step.total_steps {
let is_current = i == step.current_step;
let dot_color = if is_current {
PhenomenonStyle::surface_border()
} else {
PhenomenonStyle::subtle_border()
};
let dot = ConstrainedBox::new(
Rect::new()
.with_background_color(dot_color)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(DOT_SIZE / 2.)))
.finish(),
)
.with_width(DOT_SIZE)
.with_height(DOT_SIZE)
.finish();
row.add_child(dot);
}
row.finish()
}
fn render_actions(
&self,
step: StepStatus,
right_button: Button,
mut options: Options,
appearance: &Appearance,
) -> Box<dyn Element> {
let right_button_text = right_button.text.clone();
let right_button = self.right_button.render(
appearance,
button::Params {
content: button::Content::Label(right_button_text),
theme: &PhenomenonPrimaryButtonTheme,
options: button::Options {
on_click: Some(right_button.handler),
keystroke: right_button.keystroke.clone(),
..button::Options::default(appearance)
},
},
);
let status_dots = self.render_status_dots(step);
let mut buttons_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::End)
.with_main_axis_size(MainAxisSize::Min);
if let Some(left_button) = options.left_button.take() {
let left_button_text = left_button.text.clone();
let left_button_element = self.left_button.render(
appearance,
button::Params {
content: button::Content::Label(left_button_text),
theme: &PhenomenonSecondaryButtonTheme,
options: button::Options {
on_click: Some(left_button.handler),
keystroke: left_button.keystroke.clone(),
..button::Options::default(appearance)
},
},
);
buttons_row.add_child(
Container::new(left_button_element)
.with_margin_right(8.)
.finish(),
);
}
buttons_row.add_child(right_button);
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_child(status_dots)
.with_child(buttons_row.finish())
.finish()
}
}
+82
View File
@@ -0,0 +1,82 @@
// Onboarding library crate
mod agent_onboarding_view;
pub mod callout;
mod model;
pub mod slides;
pub mod telemetry;
/// The user's intention selected during onboarding slides.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OnboardingIntention {
Terminal,
AgentDrivenDevelopment,
}
impl std::fmt::Display for OnboardingIntention {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
OnboardingIntention::AgentDrivenDevelopment => write!(f, "agent_driven"),
OnboardingIntention::Terminal => write!(f, "terminal"),
}
}
}
pub use callout::{OnboardingCalloutView, OnboardingKeybindings};
/// User-facing names 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",
];
/// User-facing names of the Warp Drive features enabled when the terminal
/// intention is selected with Warp Drive turned on. Shared by the login slide's
/// skip-login confirmation dialog so the list stays in sync with any future
/// surfaces that need it.
pub const WARP_DRIVE_FEATURES: &[&str] = &["Warp Drive", "Session Sharing"];
cfg_if::cfg_if! {
if #[cfg(feature = "bin")] {
mod telemetry_provider;
pub use telemetry_provider::MockTelemetryContextProvider;
}
}
pub mod components;
mod visuals;
/// The default mode for new sessions, chosen during onboarding.
/// Mapped to `DefaultSessionMode` at the application boundary.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SessionDefault {
#[default]
Agent,
Terminal,
}
impl std::fmt::Display for SessionDefault {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SessionDefault::Agent => write!(f, "agent"),
SessionDefault::Terminal => write!(f, "terminal"),
}
}
}
pub use agent_onboarding_view::{AgentOnboardingAction, AgentOnboardingEvent, AgentOnboardingView};
pub use model::{OnboardingAuthState, SelectedSettings, UICustomizationSettings};
pub use slides::ProjectOnboardingSettings;
pub use telemetry::OnboardingEvent;
pub fn init(app: &mut warpui::AppContext) {
agent_onboarding_view::init(app);
callout::init(app);
}
+814
View File
@@ -0,0 +1,814 @@
use crate::slides::{
AgentAutonomy, AgentDevelopmentSettings, OnboardingModelInfo, ProjectOnboardingSettings,
};
use crate::telemetry::OnboardingEvent;
use crate::OnboardingIntention;
use ai::LLMId;
use warp_core::send_telemetry_from_ctx;
use warpui::{Entity, ModelContext};
/// UI customization settings chosen during the "Customize your UI" onboarding slide.
#[derive(Clone, Debug)]
pub struct UICustomizationSettings {
pub use_vertical_tabs: bool,
pub show_conversation_history: bool,
pub show_project_explorer: bool,
pub show_global_search: bool,
pub show_warp_drive: bool,
pub show_code_review_button: bool,
}
impl UICustomizationSettings {
/// Defaults for agent-first development (all features enabled).
pub fn agent_defaults() -> Self {
Self {
use_vertical_tabs: true,
show_conversation_history: true,
show_project_explorer: true,
show_global_search: true,
show_warp_drive: true,
show_code_review_button: true,
}
}
/// Defaults for terminal mode (all features disabled).
pub fn terminal_defaults() -> Self {
Self {
use_vertical_tabs: false,
show_conversation_history: false,
show_project_explorer: false,
show_global_search: false,
show_warp_drive: false,
show_code_review_button: false,
}
}
/// Returns true if any tools-panel sub-setting visible for the given
/// intention is enabled. In terminal mode the conversation-history chip is
/// hidden, so it does not count.
pub fn tools_panel_enabled(&self, intention: &OnboardingIntention) -> bool {
let conversation_visible = matches!(intention, OnboardingIntention::AgentDrivenDevelopment);
(conversation_visible && self.show_conversation_history)
|| self.show_project_explorer
|| self.show_global_search
|| self.show_warp_drive
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OnboardingAuthState {
LoggedOut,
FreeUser,
PayingUser,
}
#[derive(Clone, Debug)]
pub enum SelectedSettings {
Terminal {
ui_customization: Option<UICustomizationSettings>,
cli_agent_toolbar_enabled: bool,
show_agent_notifications: bool,
},
AgentDrivenDevelopment {
agent_settings: AgentDevelopmentSettings,
project_settings: ProjectOnboardingSettings,
ui_customization: Option<UICustomizationSettings>,
},
}
impl SelectedSettings {
pub fn is_ai_enabled(&self) -> bool {
use warp_core::features::FeatureFlag;
match self {
SelectedSettings::AgentDrivenDevelopment { agent_settings, .. } => {
!agent_settings.disable_oz
}
SelectedSettings::Terminal { .. } => {
// With old onboarding (no OpenWarpNewSettingsModes), Terminal
// intent still leaves AI enabled; with new onboarding,
// Terminal intent explicitly disables AI.
!FeatureFlag::OpenWarpNewSettingsModes.is_enabled()
}
}
}
pub fn is_warp_drive_enabled(&self) -> bool {
match self {
SelectedSettings::AgentDrivenDevelopment {
ui_customization, ..
} => ui_customization
.as_ref()
.map(|ui| ui.show_warp_drive)
.unwrap_or(true),
SelectedSettings::Terminal {
ui_customization, ..
} => ui_customization
.as_ref()
.map(|ui| ui.show_warp_drive)
.unwrap_or(false),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum OnboardingStep {
Intro,
Intention,
Customize,
Agent,
ThirdParty,
Project,
ThemePicker,
}
#[derive(Clone, Debug)]
pub(crate) enum OnboardingStateEvent {
ModelsUpdated,
SelectedSlideChanged,
IntentionChanged,
Completed,
UpgradeRequested,
AuthStateChanged,
}
#[derive(Clone, Debug)]
pub(crate) struct OnboardingStateModel {
step: OnboardingStep,
intention: OnboardingIntention,
agent_settings: AgentDevelopmentSettings,
project_settings: ProjectOnboardingSettings,
ui_customization: UICustomizationSettings,
models: Vec<OnboardingModelInfo>,
/// Whether the workspace enforces autonomy settings, hiding the user selection UI.
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>,
/// Auth / billing state of the user.
auth_state: OnboardingAuthState,
}
impl OnboardingStateModel {
/// Creates a new OnboardingStateModel.
pub(crate) fn new(
models: Vec<OnboardingModelInfo>,
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 {
step: OnboardingStep::Intro,
intention: OnboardingIntention::AgentDrivenDevelopment,
agent_settings: AgentDevelopmentSettings::new(default_model_id),
project_settings: ProjectOnboardingSettings::default(),
ui_customization: UICustomizationSettings::agent_defaults(),
models,
workspace_enforces_autonomy,
agent_modality_enabled,
free_user_no_ai_experiment,
agent_price_cents,
auth_state,
}
}
pub(crate) fn auth_state(&self) -> OnboardingAuthState {
self.auth_state
}
pub(crate) fn set_auth_state(
&mut self,
auth_state: OnboardingAuthState,
ctx: &mut ModelContext<Self>,
) {
if self.auth_state == auth_state {
return;
}
self.auth_state = auth_state;
ctx.emit(OnboardingStateEvent::AuthStateChanged);
}
pub(crate) fn settings(&self) -> SelectedSettings {
use warp_core::features::FeatureFlag;
let ui_customization = if FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
Some(self.ui_customization.clone())
} else {
None
};
match &self.intention {
OnboardingIntention::Terminal => SelectedSettings::Terminal {
ui_customization,
cli_agent_toolbar_enabled: self.agent_settings.cli_agent_toolbar_enabled,
show_agent_notifications: self.agent_settings.show_agent_notifications,
},
OnboardingIntention::AgentDrivenDevelopment => {
SelectedSettings::AgentDrivenDevelopment {
agent_settings: AgentDevelopmentSettings {
selected_model_id: self.agent_settings.selected_model_id.clone(),
autonomy: if self.workspace_enforces_autonomy {
None
} else {
self.agent_settings.autonomy
},
cli_agent_toolbar_enabled: self.agent_settings.cli_agent_toolbar_enabled,
session_default: self.agent_settings.session_default,
disable_oz: self.agent_settings.disable_oz,
// Agent intention always has notifications enabled (no toggle shown).
show_agent_notifications: true,
},
project_settings: self.project_settings.clone(),
ui_customization,
}
}
}
}
pub(crate) fn step(&self) -> OnboardingStep {
self.step
}
pub(crate) fn intention(&self) -> &OnboardingIntention {
&self.intention
}
pub(crate) fn agent_settings(&self) -> &AgentDevelopmentSettings {
&self.agent_settings
}
pub(crate) fn project_settings(&self) -> &ProjectOnboardingSettings {
&self.project_settings
}
pub(crate) fn workspace_enforces_autonomy(&self) -> bool {
self.workspace_enforces_autonomy
}
pub(crate) fn agent_modality_enabled(&self) -> bool {
self.agent_modality_enabled
}
pub fn ui_customization(&self) -> &UICustomizationSettings {
&self.ui_customization
}
pub(crate) fn set_use_vertical_tabs(&mut self, value: bool, ctx: &mut ModelContext<Self>) {
if self.ui_customization.use_vertical_tabs == value {
return;
}
send_telemetry_from_ctx!(
OnboardingEvent::SettingChanged {
setting: "tab_styling".to_string(),
value: if value { "vertical" } else { "horizontal" }.to_string(),
},
ctx
);
self.ui_customization.use_vertical_tabs = value;
ctx.notify();
}
pub(crate) fn set_tools_panel_enabled(&mut self, enabled: bool, ctx: &mut ModelContext<Self>) {
send_telemetry_from_ctx!(
OnboardingEvent::SettingChanged {
setting: "tools_panel".to_string(),
value: if enabled { "enabled" } else { "disabled" }.to_string(),
},
ctx
);
self.ui_customization.show_conversation_history = enabled;
self.ui_customization.show_project_explorer = enabled;
self.ui_customization.show_global_search = enabled;
self.ui_customization.show_warp_drive = enabled;
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,
ctx: &mut ModelContext<Self>,
) {
if self.ui_customization.show_conversation_history == value {
return;
}
send_telemetry_from_ctx!(
OnboardingEvent::SettingChanged {
setting: "conversation_history".to_string(),
value: value.to_string(),
},
ctx
);
self.ui_customization.show_conversation_history = value;
ctx.notify();
}
pub(crate) fn set_show_project_explorer(&mut self, value: bool, ctx: &mut ModelContext<Self>) {
if self.ui_customization.show_project_explorer == value {
return;
}
send_telemetry_from_ctx!(
OnboardingEvent::SettingChanged {
setting: "project_explorer".to_string(),
value: value.to_string(),
},
ctx
);
self.ui_customization.show_project_explorer = value;
ctx.notify();
}
pub(crate) fn set_show_global_search(&mut self, value: bool, ctx: &mut ModelContext<Self>) {
if self.ui_customization.show_global_search == value {
return;
}
send_telemetry_from_ctx!(
OnboardingEvent::SettingChanged {
setting: "global_search".to_string(),
value: value.to_string(),
},
ctx
);
self.ui_customization.show_global_search = value;
ctx.notify();
}
pub(crate) fn set_show_warp_drive(&mut self, value: bool, ctx: &mut ModelContext<Self>) {
if self.ui_customization.show_warp_drive == value {
return;
}
send_telemetry_from_ctx!(
OnboardingEvent::SettingChanged {
setting: "warp_drive".to_string(),
value: value.to_string(),
},
ctx
);
self.ui_customization.show_warp_drive = value;
ctx.notify();
}
pub(crate) fn set_cli_agent_toolbar_enabled(
&mut self,
value: bool,
ctx: &mut ModelContext<Self>,
) {
if self.agent_settings.cli_agent_toolbar_enabled == value {
return;
}
send_telemetry_from_ctx!(
OnboardingEvent::SettingChanged {
setting: "cli_agent_toolbar".to_string(),
value: if value { "enabled" } else { "disabled" }.to_string(),
},
ctx
);
self.agent_settings.cli_agent_toolbar_enabled = value;
ctx.notify();
}
pub(crate) fn set_show_agent_notifications(
&mut self,
value: bool,
ctx: &mut ModelContext<Self>,
) {
if self.agent_settings.show_agent_notifications == value {
return;
}
send_telemetry_from_ctx!(
OnboardingEvent::SettingChanged {
setting: "show_agent_notifications".to_string(),
value: if value { "enabled" } else { "disabled" }.to_string(),
},
ctx
);
self.agent_settings.show_agent_notifications = value;
ctx.notify();
}
pub(crate) fn set_show_code_review_button(
&mut self,
value: bool,
ctx: &mut ModelContext<Self>,
) {
if self.ui_customization.show_code_review_button == value {
return;
}
send_telemetry_from_ctx!(
OnboardingEvent::SettingChanged {
setting: "code_review".to_string(),
value: if value { "enabled" } else { "disabled" }.to_string(),
},
ctx
);
self.ui_customization.show_code_review_button = value;
ctx.notify();
}
pub(crate) fn set_disable_oz(&mut self, value: bool, ctx: &mut ModelContext<Self>) {
if self.agent_settings.disable_oz == value {
return;
}
send_telemetry_from_ctx!(
OnboardingEvent::SettingChanged {
setting: "disable_oz".to_string(),
value: value.to_string(),
},
ctx
);
self.agent_settings.disable_oz = value;
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,
ctx: &mut ModelContext<Self>,
) {
if self.workspace_enforces_autonomy == value {
return;
}
self.workspace_enforces_autonomy = value;
ctx.notify();
}
pub(crate) fn models(&self) -> &Vec<OnboardingModelInfo> {
&self.models
}
fn set_intention(&mut self, intention: OnboardingIntention, ctx: &mut ModelContext<Self>) {
if self.intention == intention {
return;
}
send_telemetry_from_ctx!(
OnboardingEvent::SettingChanged {
setting: "intention".to_string(),
value: intention.to_string(),
},
ctx
);
self.intention = intention;
// Reset UI customization to defaults for the new intention.
self.ui_customization = match intention {
OnboardingIntention::AgentDrivenDevelopment => {
UICustomizationSettings::agent_defaults()
}
OnboardingIntention::Terminal => UICustomizationSettings::terminal_defaults(),
};
// Reset notifications default based on intention.
self.agent_settings.show_agent_notifications =
matches!(intention, OnboardingIntention::AgentDrivenDevelopment);
ctx.emit(OnboardingStateEvent::IntentionChanged);
ctx.notify();
}
pub(crate) fn set_intention_terminal(&mut self, ctx: &mut ModelContext<Self>) {
self.set_intention(OnboardingIntention::Terminal, ctx);
}
pub(crate) fn set_intention_agent_driven_development(&mut self, ctx: &mut ModelContext<Self>) {
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);
}
pub(crate) fn on_user_selected_model(&mut self, model_id: LLMId, ctx: &mut ModelContext<Self>) {
if self.agent_settings.selected_model_id == model_id {
return;
}
if self.is_model_disabled(&model_id) {
return;
}
send_telemetry_from_ctx!(
OnboardingEvent::SettingChanged {
setting: "model".to_string(),
value: model_id.to_string(),
},
ctx
);
self.agent_settings.selected_model_id = model_id;
ctx.notify();
}
/// Updates the list of available models.
pub(crate) fn set_models(
&mut self,
models: Vec<OnboardingModelInfo>,
default_model_id: LLMId,
ctx: &mut ModelContext<Self>,
) {
use warp_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
// so it must also be guarded.
let is_past_agent_slide = if FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
matches!(
self.step,
OnboardingStep::ThirdParty | OnboardingStep::ThemePicker
)
} else {
matches!(self.step, OnboardingStep::Project)
};
if is_past_agent_slide {
return;
}
self.agent_settings.selected_model_id = default_model_id.clone();
self.models = models;
ctx.emit(OnboardingStateEvent::ModelsUpdated);
ctx.notify();
}
pub(crate) fn set_agent_autonomy(
&mut self,
autonomy: AgentAutonomy,
ctx: &mut ModelContext<Self>,
) {
if self.workspace_enforces_autonomy || self.agent_settings.autonomy == Some(autonomy) {
return;
}
send_telemetry_from_ctx!(
OnboardingEvent::SettingChanged {
setting: "autonomy".to_string(),
value: autonomy.to_string(),
},
ctx
);
self.agent_settings.autonomy = Some(autonomy);
ctx.notify();
}
pub(crate) fn set_project_selected_local_folder(
&mut self,
path: Option<String>,
ctx: &mut ModelContext<Self>,
) {
if path.is_some() {
send_telemetry_from_ctx!(OnboardingEvent::FolderSelected, ctx);
}
self.project_settings = ProjectOnboardingSettings::from_path(path);
ctx.notify();
}
pub(crate) fn toggle_project_initialize_projects_automatically(
&mut self,
ctx: &mut ModelContext<Self>,
) {
if let ProjectOnboardingSettings::Project {
initialize_projects_automatically,
..
} = &mut self.project_settings
{
let new_value = !*initialize_projects_automatically;
send_telemetry_from_ctx!(
OnboardingEvent::SettingChanged {
setting: "initialize_project".to_string(),
value: new_value.to_string(),
},
ctx
);
*initialize_projects_automatically = new_value;
ctx.notify();
}
}
fn send_completion_telemetry(&self, ctx: &mut ModelContext<Self>) {
let (intention, model, autonomy) = match &self.intention {
OnboardingIntention::Terminal => (self.intention.to_string(), 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()),
),
};
let has_project_path = matches!(
self.project_settings,
ProjectOnboardingSettings::Project { .. }
);
send_telemetry_from_ctx!(
OnboardingEvent::OnboardingSlidesCompleted {
intention,
model,
autonomy,
has_project_path,
},
ctx
);
}
pub(crate) fn complete(&mut self, ctx: &mut ModelContext<Self>) {
self.send_completion_telemetry(ctx);
ctx.emit(OnboardingStateEvent::Completed);
ctx.notify();
}
pub(crate) fn back(&mut self, ctx: &mut ModelContext<Self>) {
use warp_core::features::FeatureFlag;
let theme_picker_last = FeatureFlag::OpenWarpNewSettingsModes.is_enabled();
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::Project => Some(OnboardingStep::ThirdParty),
OnboardingStep::ThemePicker => Some(OnboardingStep::ThirdParty),
}
} else {
match self.step {
OnboardingStep::Intro => None,
OnboardingStep::ThemePicker => Some(OnboardingStep::Intro),
OnboardingStep::Intention => Some(OnboardingStep::ThemePicker),
OnboardingStep::Customize => None,
OnboardingStep::ThirdParty => None,
OnboardingStep::Agent => Some(OnboardingStep::Intention),
OnboardingStep::Project => Some(OnboardingStep::Agent),
}
};
if let Some(prev) = prev {
send_telemetry_from_ctx!(OnboardingEvent::SlideNavigatedBack, ctx);
self.set_step(prev, ctx);
}
}
pub(crate) fn next(&mut self, ctx: &mut ModelContext<Self>) {
use warp_core::features::FeatureFlag;
let theme_picker_last = FeatureFlag::OpenWarpNewSettingsModes.is_enabled();
let is_last_step = if theme_picker_last {
matches!(self.step, OnboardingStep::ThemePicker)
} else {
matches!(self.step, OnboardingStep::Project)
};
if !is_last_step {
send_telemetry_from_ctx!(OnboardingEvent::SlideNavigatedNext, ctx);
}
if theme_picker_last {
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),
OnboardingIntention::AgentDrivenDevelopment => {
self.set_step(OnboardingStep::Agent, ctx)
}
},
OnboardingStep::Agent => self.set_step(OnboardingStep::ThirdParty, ctx),
OnboardingStep::ThirdParty => self.set_step(OnboardingStep::ThemePicker, ctx),
OnboardingStep::Project => self.set_step(OnboardingStep::ThemePicker, ctx),
OnboardingStep::ThemePicker => {}
}
} else {
match self.step {
OnboardingStep::Intro => self.set_step(OnboardingStep::ThemePicker, ctx),
OnboardingStep::ThemePicker => self.set_step(OnboardingStep::Intention, ctx),
OnboardingStep::Intention => self.set_step(OnboardingStep::Agent, ctx),
OnboardingStep::Customize => {}
OnboardingStep::ThirdParty => {}
OnboardingStep::Agent => self.set_step(OnboardingStep::Project, ctx),
OnboardingStep::Project => {}
}
}
}
pub(crate) fn set_step(&mut self, step: OnboardingStep, ctx: &mut ModelContext<Self>) {
if self.step == step {
return;
}
self.step = step;
match step {
OnboardingStep::Intro => {
send_telemetry_from_ctx!(
OnboardingEvent::SlideViewed {
slide_name: "intro".to_string(),
},
ctx
);
}
OnboardingStep::ThemePicker => {
send_telemetry_from_ctx!(
OnboardingEvent::SlideViewed {
slide_name: "theme_picker".to_string(),
},
ctx
);
}
OnboardingStep::Intention => {
send_telemetry_from_ctx!(
OnboardingEvent::SlideViewed {
slide_name: "intention".to_string(),
},
ctx
);
}
OnboardingStep::Customize => {
send_telemetry_from_ctx!(
OnboardingEvent::SlideViewed {
slide_name: "customize".to_string(),
},
ctx
);
}
OnboardingStep::Agent => {
send_telemetry_from_ctx!(
OnboardingEvent::SlideViewed {
slide_name: "agent".to_string(),
},
ctx
);
}
OnboardingStep::ThirdParty => {
send_telemetry_from_ctx!(
OnboardingEvent::SlideViewed {
slide_name: "third_party".to_string(),
},
ctx
);
}
OnboardingStep::Project => {
send_telemetry_from_ctx!(
OnboardingEvent::SlideViewed {
slide_name: "project".to_string(),
},
ctx
);
}
}
ctx.emit(OnboardingStateEvent::SelectedSlideChanged);
ctx.notify();
}
}
impl Entity for OnboardingStateModel {
type Event = OnboardingStateEvent;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
use crate::slides::progress_dots;
use warp_core::ui::appearance::Appearance;
use warpui::{
elements::{
Align, Container, CrossAxisAlignment, Empty, Flex, MainAxisSize, ParentElement, Shrinkable,
},
Element,
};
pub fn onboarding_bottom_nav(
appearance: &Appearance,
step_index: usize,
step_count: usize,
back_button: Option<Box<dyn Element>>,
next_button: Option<Box<dyn Element>>,
) -> Box<dyn Element> {
let dots = progress_dots::progress_dots(step_count, step_index, appearance);
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.
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()
}
@@ -0,0 +1,820 @@
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 ui_components::{button, Component as _, Options as _};
use warp_core::features::FeatureFlag;
use warp_core::ui::{appearance::Appearance, theme::color::internal_colors};
use warpui::prelude::Align;
use warpui::{
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,
};
/// Which setting card is currently selected (expanded).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SettingCard {
TabStyling,
ToolsPanel,
CodeReview,
}
/// Sub-settings within the tools panel.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ToolsPanelSubSetting {
ConversationHistory,
ProjectExplorer,
GlobalSearch,
WarpDrive,
}
#[derive(Debug, Clone)]
pub enum CustomizeSlideAction {
SelectSettingCard { card_index: usize },
SetTabStylingVertical { vertical: bool },
SetToolsPanelEnabled { enabled: bool },
ToggleToolsSubSetting { setting: ToolsPanelSubSetting },
HoverToolsChip { setting: ToolsPanelSubSetting },
SetCodeReviewEnabled { enabled: bool },
BackClicked,
NextClicked,
}
pub struct CustomizeUISlide {
onboarding_state: ModelHandle<OnboardingStateModel>,
selected_setting: Option<SettingCard>,
/// The last-hovered tools panel chip; persists until a different chip is hovered
/// or a different card is selected.
hovered_chip: Option<ToolsPanelSubSetting>,
// Mouse states for setting cards
tab_styling_mouse_state: MouseStateHandle,
tools_panel_mouse_state: MouseStateHandle,
code_review_mouse_state: MouseStateHandle,
// Mouse states for segmented control options (2 per card)
tab_seg_left_mouse: MouseStateHandle,
tab_seg_right_mouse: MouseStateHandle,
tools_seg_left_mouse: MouseStateHandle,
tools_seg_right_mouse: MouseStateHandle,
code_seg_left_mouse: MouseStateHandle,
code_seg_right_mouse: MouseStateHandle,
// Mouse states for tools panel chip buttons
chip_conversation_mouse: MouseStateHandle,
chip_file_explorer_mouse: MouseStateHandle,
chip_global_search_mouse: MouseStateHandle,
chip_warp_drive_mouse: MouseStateHandle,
// Buttons
back_button: button::Button,
next_button: button::Button,
scroll_state: ClippedScrollStateHandle,
}
impl CustomizeUISlide {
pub(crate) fn new(
onboarding_state: ModelHandle<OnboardingStateModel>,
ctx: &mut ViewContext<Self>,
) -> Self {
ctx.subscribe_to_model(&onboarding_state, |me, _model, event, ctx| {
if matches!(event, OnboardingStateEvent::IntentionChanged) {
me.selected_setting = None;
me.hovered_chip = None;
ctx.notify();
}
});
Self {
onboarding_state,
selected_setting: None,
hovered_chip: None,
tab_styling_mouse_state: MouseStateHandle::default(),
tools_panel_mouse_state: MouseStateHandle::default(),
code_review_mouse_state: MouseStateHandle::default(),
tab_seg_left_mouse: MouseStateHandle::default(),
tab_seg_right_mouse: MouseStateHandle::default(),
tools_seg_left_mouse: MouseStateHandle::default(),
tools_seg_right_mouse: MouseStateHandle::default(),
code_seg_left_mouse: MouseStateHandle::default(),
code_seg_right_mouse: MouseStateHandle::default(),
chip_conversation_mouse: MouseStateHandle::default(),
chip_file_explorer_mouse: MouseStateHandle::default(),
chip_global_search_mouse: MouseStateHandle::default(),
chip_warp_drive_mouse: MouseStateHandle::default(),
back_button: button::Button::default(),
next_button: button::Button::default(),
scroll_state: ClippedScrollStateHandle::new(),
}
}
fn model_intention(&self, app: &AppContext) -> OnboardingIntention {
*self.onboarding_state.as_ref(app).intention()
}
fn model_ui_customization(&self, app: &AppContext) -> UICustomizationSettings {
self.onboarding_state.as_ref(app).ui_customization().clone()
}
fn render_content(
&self,
appearance: &Appearance,
intention: OnboardingIntention,
ui: &UICustomizationSettings,
) -> Box<dyn Element> {
let bottom_nav = Align::new(self.render_bottom_nav(appearance, intention)).finish();
slide_content::onboarding_slide_content(
vec![
Align::new(self.render_header(appearance)).left().finish(),
self.render_setting_cards(appearance, intention, ui),
],
bottom_nav,
self.scroll_state.clone(),
appearance,
)
}
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
let title = appearance
.ui_builder()
.paragraph("Customize your Warp")
.with_style(UiComponentStyles {
font_size: Some(36.),
font_weight: Some(Weight::Medium),
..Default::default()
})
.build()
.finish();
let subtitle = FormattedTextElement::from_str(
"Tailor your features and UI to your working style.",
appearance.ui_font_family(),
16.,
)
.with_color(internal_colors::text_sub(
appearance.theme(),
appearance.theme().background().into_solid(),
))
.with_weight(Weight::Normal)
.with_alignment(TextAlignment::Left)
.with_line_height_ratio(1.0)
.finish();
Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(title)
.with_child(
Container::new(subtitle)
.with_margin_top(16.)
.with_margin_bottom(40.)
.finish(),
)
.finish()
}
// --- Setting cards ---
fn render_setting_cards(
&self,
appearance: &Appearance,
intention: OnboardingIntention,
ui: &UICustomizationSettings,
) -> Box<dyn Element> {
let tab_card = self.render_tab_styling_card(appearance, ui);
let tools_card = self.render_tools_panel_card(appearance, intention, ui);
let code_card = self.render_code_review_card(appearance, ui);
Container::new(
Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_spacing(12.)
.with_child(tab_card)
.with_child(tools_card)
.with_child(code_card)
.finish(),
)
.with_margin_top(12.)
.finish()
}
fn render_tab_styling_card(
&self,
appearance: &Appearance,
ui: &UICustomizationSettings,
) -> Box<dyn Element> {
let is_selected = self.selected_setting == Some(SettingCard::TabStyling);
render_toggle_card(
appearance,
ToggleCardSpec {
title: "Tab styling",
is_expanded: is_selected,
is_left_selected: ui.use_vertical_tabs,
left_label: "Vertical",
right_label: "Horizontal",
card_mouse_state: self.tab_styling_mouse_state.clone(),
on_expand: Box::new(|ctx, _, _| {
ctx.dispatch_typed_action(CustomizeSlideAction::SelectSettingCard {
card_index: 0,
});
}),
left_mouse: self.tab_seg_left_mouse.clone(),
right_mouse: self.tab_seg_right_mouse.clone(),
on_left: Box::new(|ctx, _, _| {
ctx.dispatch_typed_action(CustomizeSlideAction::SetTabStylingVertical {
vertical: true,
});
}),
on_right: Box::new(|ctx, _, _| {
ctx.dispatch_typed_action(CustomizeSlideAction::SetTabStylingVertical {
vertical: false,
});
}),
chips: vec![],
},
)
}
fn render_tools_panel_card(
&self,
appearance: &Appearance,
intention: OnboardingIntention,
ui: &UICustomizationSettings,
) -> Box<dyn Element> {
let is_selected = self.selected_setting == Some(SettingCard::ToolsPanel);
let is_agent = matches!(intention, OnboardingIntention::AgentDrivenDevelopment);
let mut chips = vec![];
if ui.tools_panel_enabled(&intention) {
// Conversation history chip is only shown for the agent intention.
if is_agent {
chips.push(ChipSpec {
label: "Conversation history",
is_enabled: ui.show_conversation_history,
mouse_state: self.chip_conversation_mouse.clone(),
on_click: Box::new(|ctx, _, _| {
ctx.dispatch_typed_action(CustomizeSlideAction::ToggleToolsSubSetting {
setting: ToolsPanelSubSetting::ConversationHistory,
});
}),
on_hover: Some(Box::new(|is_hovered, ctx, _, _| {
if is_hovered {
ctx.dispatch_typed_action(CustomizeSlideAction::HoverToolsChip {
setting: ToolsPanelSubSetting::ConversationHistory,
});
}
})),
});
}
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,
mouse_state: self.chip_global_search_mouse.clone(),
on_click: Box::new(|ctx, _, _| {
ctx.dispatch_typed_action(CustomizeSlideAction::ToggleToolsSubSetting {
setting: ToolsPanelSubSetting::GlobalSearch,
});
}),
on_hover: Some(Box::new(|is_hovered, ctx, _, _| {
if is_hovered {
ctx.dispatch_typed_action(CustomizeSlideAction::HoverToolsChip {
setting: ToolsPanelSubSetting::GlobalSearch,
});
}
})),
});
chips.push(ChipSpec {
label: "Warp Drive",
is_enabled: ui.show_warp_drive,
mouse_state: self.chip_warp_drive_mouse.clone(),
on_click: Box::new(|ctx, _, _| {
ctx.dispatch_typed_action(CustomizeSlideAction::ToggleToolsSubSetting {
setting: ToolsPanelSubSetting::WarpDrive,
});
}),
on_hover: Some(Box::new(|is_hovered, ctx, _, _| {
if is_hovered {
ctx.dispatch_typed_action(CustomizeSlideAction::HoverToolsChip {
setting: ToolsPanelSubSetting::WarpDrive,
});
}
})),
});
}
render_toggle_card(
appearance,
ToggleCardSpec {
title: "Tools panel",
is_expanded: is_selected,
is_left_selected: ui.tools_panel_enabled(&intention),
left_label: "Enabled",
right_label: "Disabled",
card_mouse_state: self.tools_panel_mouse_state.clone(),
on_expand: Box::new(|ctx, _, _| {
ctx.dispatch_typed_action(CustomizeSlideAction::SelectSettingCard {
card_index: 1,
});
}),
left_mouse: self.tools_seg_left_mouse.clone(),
right_mouse: self.tools_seg_right_mouse.clone(),
on_left: Box::new(|ctx, _, _| {
ctx.dispatch_typed_action(CustomizeSlideAction::SetToolsPanelEnabled {
enabled: true,
});
}),
on_right: Box::new(|ctx, _, _| {
ctx.dispatch_typed_action(CustomizeSlideAction::SetToolsPanelEnabled {
enabled: false,
});
}),
chips,
},
)
}
fn render_code_review_card(
&self,
appearance: &Appearance,
ui: &UICustomizationSettings,
) -> Box<dyn Element> {
let is_selected = self.selected_setting == Some(SettingCard::CodeReview);
render_toggle_card(
appearance,
ToggleCardSpec {
title: "Code review",
is_expanded: is_selected,
is_left_selected: ui.show_code_review_button,
left_label: "Enabled",
right_label: "Disabled",
card_mouse_state: self.code_review_mouse_state.clone(),
on_expand: Box::new(|ctx, _, _| {
ctx.dispatch_typed_action(CustomizeSlideAction::SelectSettingCard {
card_index: 2,
});
}),
left_mouse: self.code_seg_left_mouse.clone(),
right_mouse: self.code_seg_right_mouse.clone(),
on_left: Box::new(|ctx, _, _| {
ctx.dispatch_typed_action(CustomizeSlideAction::SetCodeReviewEnabled {
enabled: true,
});
}),
on_right: Box::new(|ctx, _, _| {
ctx.dispatch_typed_action(CustomizeSlideAction::SetCodeReviewEnabled {
enabled: false,
});
}),
chips: vec![],
},
)
}
// --- Bottom nav ---
fn render_bottom_nav(
&self,
appearance: &Appearance,
intention: OnboardingIntention,
) -> 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(CustomizeSlideAction::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(CustomizeSlideAction::NextClicked);
})),
..button::Options::default(appearance)
},
},
);
let is_terminal = matches!(intention, OnboardingIntention::Terminal);
let (step_index, step_count) = if is_terminal { (1, 4) } else { (1, 5) };
bottom_nav::onboarding_bottom_nav(
appearance,
step_index,
step_count,
Some(back_button),
Some(next_button),
)
}
// --- Visual (right column) ---
/// All bundled image paths used by the customize slide visual.
/// Used for preloading into the asset cache.
pub(crate) const VISUAL_IMAGE_PATHS: &'static [&'static str] = &[
// Welcome / default
"async/png/onboarding/welcome_agent.png",
"async/png/onboarding/welcome_terminal.png",
// Agent intention
"async/png/onboarding/agent_intention/customize_vertical_tabs.png",
"async/png/onboarding/agent_intention/customize_horizontal_tabs.png",
"async/png/onboarding/agent_intention/customize_tools_disabled_vertical.png",
"async/png/onboarding/agent_intention/customize_tools_disabled_horizontal.png",
"async/png/onboarding/agent_intention/customize_conversation_vertical.png",
"async/png/onboarding/agent_intention/customize_conversation_horizontal.png",
"async/png/onboarding/agent_intention/customize_fileexplorer_vertical.png",
"async/png/onboarding/agent_intention/customize_fileexplorer_horizontal.png",
"async/png/onboarding/agent_intention/customize_filesearch_vertical.png",
"async/png/onboarding/agent_intention/customize_filesearch_horizontal.png",
"async/png/onboarding/agent_intention/customize_warpdrive_vertical.png",
"async/png/onboarding/agent_intention/customize_warpdrive_horizontal.png",
"async/png/onboarding/agent_intention/customize_codereview_enabled_vertical.png",
"async/png/onboarding/agent_intention/customize_codereview_enabled_horizontal.png",
"async/png/onboarding/agent_intention/customize_codereview_disabled_vertical.png",
"async/png/onboarding/agent_intention/customize_codereview_disabled_horizontal.png",
// Terminal intention
"async/png/onboarding/terminal_intention/terminal_customize_vertical_tabs.png",
"async/png/onboarding/terminal_intention/terminal_customize_horizontal_tabs.png",
"async/png/onboarding/terminal_intention/terminal_customize_fileexplorer_vertical.png",
"async/png/onboarding/terminal_intention/terminal_customize_fileexplorer_horizontal.png",
"async/png/onboarding/terminal_intention/terminal_customize_filesearch_vertical.png",
"async/png/onboarding/terminal_intention/terminal_customize_filesearch_horizontal.png",
"async/png/onboarding/terminal_intention/terminal_customize_warpdrive_vertical.png",
"async/png/onboarding/terminal_intention/terminal_customize_warpdrive_horizontal.png",
"async/png/onboarding/terminal_intention/terminal_codereview_enabled.png",
"async/png/onboarding/terminal_intention/terminal_codereview_disabled.png",
];
/// Returns the image path for the current visual state.
/// When `OpenWarpNewSettingsModes` is enabled, assets depend on the tab layout setting.
fn visual_image_path(
selected_setting: Option<SettingCard>,
hovered_chip: Option<ToolsPanelSubSetting>,
intention: OnboardingIntention,
ui: &UICustomizationSettings,
) -> &'static str {
let is_agent = matches!(intention, OnboardingIntention::AgentDrivenDevelopment);
let vertical = ui.use_vertical_tabs;
match selected_setting {
None => match intention {
OnboardingIntention::AgentDrivenDevelopment => {
"async/png/onboarding/welcome_agent.png"
}
OnboardingIntention::Terminal => "async/png/onboarding/welcome_terminal.png",
},
Some(SettingCard::TabStyling) => {
if is_agent {
if !ui.tools_panel_enabled(&intention) {
if vertical {
"async/png/onboarding/agent_intention/customize_tools_disabled_vertical.png"
} else {
"async/png/onboarding/agent_intention/customize_tools_disabled_horizontal.png"
}
} else if vertical {
"async/png/onboarding/agent_intention/customize_vertical_tabs.png"
} else {
"async/png/onboarding/agent_intention/customize_horizontal_tabs.png"
}
} else if vertical {
"async/png/onboarding/terminal_intention/terminal_customize_vertical_tabs.png"
} else {
"async/png/onboarding/terminal_intention/terminal_customize_horizontal_tabs.png"
}
}
Some(SettingCard::ToolsPanel) => {
if !ui.tools_panel_enabled(&intention) {
// Terminal: tools disabled uses the same image as tab layout.
if is_agent {
if vertical {
"async/png/onboarding/agent_intention/customize_tools_disabled_vertical.png"
} else {
"async/png/onboarding/agent_intention/customize_tools_disabled_horizontal.png"
}
} else if vertical {
"async/png/onboarding/terminal_intention/terminal_customize_vertical_tabs.png"
} else {
"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
};
let chip = hovered_chip.unwrap_or(default_chip);
if is_agent {
match (chip, vertical) {
(ToolsPanelSubSetting::ConversationHistory, true) => "async/png/onboarding/agent_intention/customize_conversation_vertical.png",
(ToolsPanelSubSetting::ConversationHistory, false) => "async/png/onboarding/agent_intention/customize_conversation_horizontal.png",
(ToolsPanelSubSetting::ProjectExplorer, true) => "async/png/onboarding/agent_intention/customize_fileexplorer_vertical.png",
(ToolsPanelSubSetting::ProjectExplorer, false) => "async/png/onboarding/agent_intention/customize_fileexplorer_horizontal.png",
(ToolsPanelSubSetting::GlobalSearch, true) => "async/png/onboarding/agent_intention/customize_filesearch_vertical.png",
(ToolsPanelSubSetting::GlobalSearch, false) => "async/png/onboarding/agent_intention/customize_filesearch_horizontal.png",
(ToolsPanelSubSetting::WarpDrive, true) => "async/png/onboarding/agent_intention/customize_warpdrive_vertical.png",
(ToolsPanelSubSetting::WarpDrive, false) => "async/png/onboarding/agent_intention/customize_warpdrive_horizontal.png",
}
} else {
// Terminal: no conversation chip; ConversationHistory falls through to file explorer.
match (chip, vertical) {
(ToolsPanelSubSetting::ConversationHistory | ToolsPanelSubSetting::ProjectExplorer, true) => "async/png/onboarding/terminal_intention/terminal_customize_fileexplorer_vertical.png",
(ToolsPanelSubSetting::ConversationHistory | ToolsPanelSubSetting::ProjectExplorer, false) => "async/png/onboarding/terminal_intention/terminal_customize_fileexplorer_horizontal.png",
(ToolsPanelSubSetting::GlobalSearch, true) => "async/png/onboarding/terminal_intention/terminal_customize_filesearch_vertical.png",
(ToolsPanelSubSetting::GlobalSearch, false) => "async/png/onboarding/terminal_intention/terminal_customize_filesearch_horizontal.png",
(ToolsPanelSubSetting::WarpDrive, true) => "async/png/onboarding/terminal_intention/terminal_customize_warpdrive_vertical.png",
(ToolsPanelSubSetting::WarpDrive, false) => "async/png/onboarding/terminal_intention/terminal_customize_warpdrive_horizontal.png",
}
}
}
}
Some(SettingCard::CodeReview) => {
if is_agent {
match (ui.show_code_review_button, vertical) {
(true, true) => "async/png/onboarding/agent_intention/customize_codereview_enabled_vertical.png",
(true, false) => "async/png/onboarding/agent_intention/customize_codereview_enabled_horizontal.png",
(false, true) => "async/png/onboarding/agent_intention/customize_codereview_disabled_vertical.png",
(false, false) => "async/png/onboarding/agent_intention/customize_codereview_disabled_horizontal.png",
}
} else if ui.show_code_review_button {
"async/png/onboarding/terminal_intention/terminal_codereview_enabled.png"
} else {
"async/png/onboarding/terminal_intention/terminal_codereview_disabled.png"
}
}
}
}
fn render_visual(
&self,
appearance: &Appearance,
intention: OnboardingIntention,
ui: &UICustomizationSettings,
) -> Box<dyn Element> {
let theme = appearance.theme();
if FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
let path =
Self::visual_image_path(self.selected_setting, self.hovered_chip, intention, ui);
let fg_layout = match self.selected_setting {
None => layout::FOREGROUND_LAYOUT_DEFAULT,
Some(SettingCard::CodeReview) => layout::FOREGROUND_LAYOUT_CODE_REVIEW,
_ => layout::FOREGROUND_LAYOUT_WIDE,
};
layout::onboarding_right_panel_with_bg(path, fg_layout)
} else {
let panel_background = internal_colors::neutral_2(theme);
let neutral = internal_colors::neutral_4(theme);
let visual = if matches!(intention, OnboardingIntention::Terminal) {
let neutral_highlight = internal_colors::neutral_6(theme);
let accent = internal_colors::accent(theme);
intention_terminal_visual(
panel_background,
neutral,
neutral_highlight,
accent.into_solid(),
)
} else {
let blue = theme.ansi_fg_blue();
let green = theme.ansi_fg_green();
let yellow = theme.ansi_fg_yellow();
intention_visual(panel_background, neutral, blue, green, yellow)
};
Container::new(visual)
.with_background_color(internal_colors::neutral_1(theme))
.finish()
}
}
}
impl Entity for CustomizeUISlide {
type Event = ();
}
impl View for CustomizeUISlide {
fn ui_name() -> &'static str {
"CustomizeUISlide"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let intention = self.model_intention(app);
let ui = self.model_ui_customization(app);
layout::static_left(
|| self.render_content(appearance, intention, &ui),
|| self.render_visual(appearance, intention, &ui),
)
}
}
impl CustomizeUISlide {
fn select_setting_card(&mut self, card_index: usize, ctx: &mut ViewContext<Self>) {
let card = match card_index {
0 => SettingCard::TabStyling,
1 => SettingCard::ToolsPanel,
2 => SettingCard::CodeReview,
_ => return,
};
// Only select — don't toggle. Clicking a different card replaces the selection.
self.selected_setting = Some(card);
// Reset chip hover when switching cards.
self.hovered_chip = None;
ctx.notify();
}
fn next(&mut self, ctx: &mut ViewContext<Self>) {
self.onboarding_state.update(ctx, |model, ctx| {
model.next(ctx);
});
}
}
impl OnboardingSlide for CustomizeUISlide {
fn on_up(&mut self, ctx: &mut ViewContext<Self>) {
// Move setting selection up
self.selected_setting = match self.selected_setting {
Some(SettingCard::ToolsPanel) => Some(SettingCard::TabStyling),
Some(SettingCard::CodeReview) => Some(SettingCard::ToolsPanel),
_ => self.selected_setting,
};
ctx.notify();
}
fn on_down(&mut self, ctx: &mut ViewContext<Self>) {
self.selected_setting = match self.selected_setting {
Some(SettingCard::TabStyling) => Some(SettingCard::ToolsPanel),
Some(SettingCard::ToolsPanel) => Some(SettingCard::CodeReview),
None => Some(SettingCard::TabStyling),
other => other,
};
ctx.notify();
}
fn on_left(&mut self, ctx: &mut ViewContext<Self>) {
match self.selected_setting {
Some(SettingCard::TabStyling) => {
self.onboarding_state.update(ctx, |model, ctx| {
model.set_use_vertical_tabs(true, ctx);
});
ctx.notify();
}
Some(SettingCard::ToolsPanel) => {
self.onboarding_state.update(ctx, |model, ctx| {
model.set_tools_panel_enabled(true, ctx);
});
ctx.notify();
}
Some(SettingCard::CodeReview) => {
self.onboarding_state.update(ctx, |model, ctx| {
model.set_show_code_review_button(true, ctx);
});
ctx.notify();
}
None => {}
}
}
fn on_right(&mut self, ctx: &mut ViewContext<Self>) {
match self.selected_setting {
Some(SettingCard::TabStyling) => {
self.onboarding_state.update(ctx, |model, ctx| {
model.set_use_vertical_tabs(false, ctx);
});
ctx.notify();
}
Some(SettingCard::ToolsPanel) => {
self.hovered_chip = None;
self.onboarding_state.update(ctx, |model, ctx| {
model.set_tools_panel_enabled(false, ctx);
});
ctx.notify();
}
Some(SettingCard::CodeReview) => {
self.onboarding_state.update(ctx, |model, ctx| {
model.set_show_code_review_button(false, ctx);
});
ctx.notify();
}
None => {}
}
}
fn on_enter(&mut self, ctx: &mut ViewContext<Self>) {
self.next(ctx);
}
}
impl TypedActionView for CustomizeUISlide {
type Action = CustomizeSlideAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
CustomizeSlideAction::SelectSettingCard { card_index } => {
self.select_setting_card(*card_index, ctx);
}
CustomizeSlideAction::SetTabStylingVertical { vertical } => {
let value = *vertical;
self.onboarding_state.update(ctx, |model, ctx| {
model.set_use_vertical_tabs(value, ctx);
});
ctx.notify();
}
CustomizeSlideAction::SetToolsPanelEnabled { enabled } => {
let value = *enabled;
if !value {
self.hovered_chip = None;
}
self.onboarding_state.update(ctx, |model, ctx| {
model.set_tools_panel_enabled(value, ctx);
});
ctx.notify();
}
CustomizeSlideAction::HoverToolsChip { setting } => {
self.hovered_chip = Some(*setting);
ctx.notify();
}
CustomizeSlideAction::ToggleToolsSubSetting { setting } => {
let setting = *setting;
self.onboarding_state
.update(ctx, |model, ctx| match setting {
ToolsPanelSubSetting::ConversationHistory => {
let current = model.ui_customization().show_conversation_history;
model.set_show_conversation_history(!current, ctx);
}
ToolsPanelSubSetting::ProjectExplorer => {
let current = model.ui_customization().show_project_explorer;
model.set_show_project_explorer(!current, ctx);
}
ToolsPanelSubSetting::GlobalSearch => {
let current = model.ui_customization().show_global_search;
model.set_show_global_search(!current, ctx);
}
ToolsPanelSubSetting::WarpDrive => {
let current = model.ui_customization().show_warp_drive;
model.set_show_warp_drive(!current, ctx);
}
});
ctx.notify();
}
CustomizeSlideAction::SetCodeReviewEnabled { enabled } => {
let value = *enabled;
self.onboarding_state.update(ctx, |model, ctx| {
model.set_show_code_review_button(value, ctx);
});
ctx.notify();
}
CustomizeSlideAction::BackClicked => {
let onboarding_state = self.onboarding_state.clone();
onboarding_state.update(ctx, |model, ctx| {
model.back(ctx);
});
}
CustomizeSlideAction::NextClicked => {
self.next(ctx);
}
}
}
}
@@ -0,0 +1,578 @@
use super::OnboardingSlide;
use crate::model::OnboardingStateModel;
use crate::slides::{bottom_nav, layout, slide_content};
use crate::telemetry::OnboardingEvent;
use crate::OnboardingIntention;
use ui_components::{button, Component as _, Options as _};
use warp_core::send_telemetry_from_ctx;
use warp_core::ui::theme::Fill;
use warp_core::ui::{appearance::Appearance, theme::color::internal_colors, Icon};
use warpui::prelude::Align;
use warpui::{
elements::{
Border, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, DropShadow, Flex, FormattedTextElement, Hoverable, MainAxisAlignment,
MainAxisSize, MouseStateHandle, ParentElement, Radius, SizeConstraintCondition,
SizeConstraintSwitch,
},
fonts::Weight,
keymap::Keystroke,
platform::Cursor,
text_layout::TextAlignment,
ui_components::components::{UiComponent as _, UiComponentStyles},
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
ViewContext,
};
const SUBSCRIBE_ITEMS: &[&str] = &[
"1,500 credits per month",
"Access to frontier OpenAI, Anthropic, and Google models",
"Access to Reload credits and volume-based discounts",
"Extended cloud agents access",
"Highest codebase indexing limits",
"Unlimited Warp Drive objects and collaboration",
"Private email support",
"Unlimited cloud conversation storage",
];
#[derive(Debug, Clone)]
pub enum FreeUserNoAiSlideAction {
SelectAgent,
SelectTerminal,
BackClicked,
NextClicked,
UpgradeClicked,
}
pub struct FreeUserNoAiSlide {
onboarding_state: ModelHandle<OnboardingStateModel>,
agent_mouse_state: MouseStateHandle,
classic_terminal_mouse_state: MouseStateHandle,
subscribe_panel_mouse_state: MouseStateHandle,
back_button: button::Button,
next_button: button::Button,
subscribe_nav_button: button::Button,
scroll_state: ClippedScrollStateHandle,
}
impl FreeUserNoAiSlide {
pub(crate) fn new(onboarding_state: ModelHandle<OnboardingStateModel>) -> Self {
Self {
onboarding_state,
agent_mouse_state: MouseStateHandle::default(),
classic_terminal_mouse_state: MouseStateHandle::default(),
subscribe_panel_mouse_state: MouseStateHandle::default(),
back_button: button::Button::default(),
next_button: button::Button::default(),
subscribe_nav_button: button::Button::default(),
scroll_state: ClippedScrollStateHandle::new(),
}
}
fn model_intention(&self, app: &AppContext) -> OnboardingIntention {
*self.onboarding_state.as_ref(app).intention()
}
fn render_content(
&self,
appearance: &Appearance,
selected_index: usize,
// i.e. when window is small, the "subscribe" button replaces the "next" button instead of just
// living on the CTA in the right pane
subscribe_in_nav: bool,
agent_price_badge: &str,
) -> Box<dyn Element> {
let bottom_nav =
Align::new(self.render_bottom_nav(appearance, selected_index, subscribe_in_nav))
.finish();
slide_content::onboarding_slide_content(
vec![
Align::new(self.render_header(appearance)).left().finish(),
Align::new(self.render_options(appearance, selected_index, agent_price_badge))
.finish(),
],
bottom_nav,
self.scroll_state.clone(),
appearance,
)
}
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
appearance
.ui_builder()
.paragraph("Let's get started.")
.with_style(UiComponentStyles {
font_size: Some(36.),
font_weight: Some(Weight::Medium),
..Default::default()
})
.build()
.finish()
}
fn render_options(
&self,
appearance: &Appearance,
selected_index: usize,
agent_price_badge: &str,
) -> Box<dyn Element> {
let agent_button = self.render_option_button(
appearance,
0,
Icon::Code2,
"Agent driven development with Warp's built-in agent",
"Iterate, plan, and build with Oz: Warp's built-in agent. Available locally or in the cloud.",
agent_price_badge.to_string(),
true, // badge is green
self.agent_mouse_state.clone(),
selected_index,
FreeUserNoAiSlideAction::SelectAgent,
);
let terminal_button = self.render_option_button(
appearance,
1,
Icon::Terminal,
"Classic terminal with third-party agents",
"A modern terminal that supports third-party agents (Claude Code, Codex, Gemini CLI) and classic terminal workflows.",
"Free".to_string(),
false, // badge is gray
self.classic_terminal_mouse_state.clone(),
selected_index,
FreeUserNoAiSlideAction::SelectTerminal,
);
Container::new(
Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(
Container::new(agent_button)
.with_margin_bottom(12.)
.finish(),
)
.with_child(terminal_button)
.finish(),
)
.with_margin_top(32.)
.finish()
}
fn render_badge(
&self,
appearance: &Appearance,
text: String,
text_color: warpui::color::ColorU,
border_color: Fill,
) -> Box<dyn Element> {
let label = appearance
.ui_builder()
.paragraph(text)
.with_style(UiComponentStyles {
font_size: Some(11.),
font_weight: Some(Weight::Normal),
font_color: Some(text_color),
..Default::default()
})
.build()
.finish();
Container::new(label)
.with_horizontal_padding(8.)
.with_vertical_padding(3.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_border(Border::all(1.).with_border_fill(border_color))
.finish()
}
#[allow(clippy::too_many_arguments)]
fn render_option_button(
&self,
appearance: &Appearance,
index: usize,
icon: Icon,
label: &'static str,
description: &'static str,
badge_text: String,
badge_green: bool,
mouse_state: MouseStateHandle,
selected_index: usize,
action: FreeUserNoAiSlideAction,
) -> Box<dyn Element> {
const RADIUS: f32 = 8.;
let theme = appearance.theme();
let is_selected = selected_index == index;
let text_fill = if is_selected {
internal_colors::accent_fg_strong(theme)
} else {
internal_colors::text_sub(theme, theme.background().into_solid()).into()
};
let text_color = text_fill.into_solid();
let background = is_selected.then(|| internal_colors::accent_overlay_1(theme));
let border_color = if is_selected {
theme.accent()
} else {
Fill::Solid(internal_colors::neutral_4(theme))
};
let ui_font_family = appearance.ui_font_family();
// For green badges, always use green. For others ("Free"), follow the
// selected state so the chip looks active when the option is selected.
let badge_color = if badge_green {
theme.ansi_fg_green()
} else {
text_fill.into_solid()
};
let badge = self.render_badge(
appearance,
badge_text,
badge_color,
Fill::Solid(badge_color),
);
Hoverable::new(mouse_state, move |_| {
let icon_el = ConstrainedBox::new(icon.to_warpui_icon(text_fill).finish())
.with_width(20.)
.with_height(20.)
.finish();
let top_row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(icon_el)
.with_child(badge)
.finish();
let label_el = appearance
.ui_builder()
.paragraph(label)
.with_style(UiComponentStyles {
font_size: Some(14.),
font_weight: Some(Weight::Normal),
font_color: Some(text_color),
..Default::default()
})
.build()
.finish();
let description_el = FormattedTextElement::from_str(description, ui_font_family, 12.)
.with_color(text_color)
.with_weight(Weight::Normal)
.with_alignment(TextAlignment::Left)
.with_line_height_ratio(1.4)
.finish();
let content = Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_main_axis_alignment(MainAxisAlignment::Start)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(top_row)
.with_child(Container::new(label_el).with_margin_top(8.).finish())
.with_child(Container::new(description_el).with_margin_top(4.).finish())
.finish();
let mut container = Container::new(content)
.with_uniform_padding(16.)
.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(action.clone());
})
.finish()
}
fn render_bottom_nav(
&self,
appearance: &Appearance,
selected_index: usize,
subscribe_in_nav: bool,
) -> 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(FreeUserNoAiSlideAction::BackClicked);
})),
..button::Options::default(appearance)
},
},
);
let enter = Keystroke::parse("enter").unwrap_or_default();
let next_button = if selected_index == 1 {
self.next_button.render(
appearance,
button::Params {
content: button::Content::Label("Get Warping".into()),
theme: &button::themes::Primary,
options: button::Options {
keystroke: Some(enter),
on_click: Some(Box::new(|ctx, _app, _pos| {
ctx.dispatch_typed_action(FreeUserNoAiSlideAction::NextClicked);
})),
..button::Options::default(appearance)
},
},
)
} else if subscribe_in_nav {
self.subscribe_nav_button.render(
appearance,
button::Params {
content: button::Content::Label("Subscribe".into()),
theme: &button::themes::Primary,
options: button::Options {
on_click: Some(Box::new(|ctx, _app, _pos| {
ctx.dispatch_typed_action(FreeUserNoAiSlideAction::UpgradeClicked);
})),
..button::Options::default(appearance)
},
},
)
} else {
self.next_button.render(
appearance,
button::Params {
content: button::Content::Label("Next".into()),
theme: &button::themes::Primary,
options: button::Options {
disabled: true,
keystroke: Some(enter),
..button::Options::default(appearance)
},
},
)
};
bottom_nav::onboarding_bottom_nav(appearance, 1, 4, Some(back_button), Some(next_button))
}
fn render_subscribe_panel(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let ui_font_family = appearance.ui_font_family();
let card_bg = theme.surface_2();
let text_main = internal_colors::text_main(theme, internal_colors::neutral_2(theme));
let text_sub = internal_colors::text_sub(theme, internal_colors::neutral_2(theme));
let title = FormattedTextElement::from_str(
"Subscribe to access agent driven development in Warp.",
ui_font_family,
24.,
)
.with_color(text_main)
.with_weight(Weight::Medium)
.with_alignment(TextAlignment::Left)
.with_line_height_ratio(1.3)
.finish();
let mut items_col = Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(8.);
for item_text in SUBSCRIBE_ITEMS {
let bullet = appearance
.ui_builder()
.paragraph(format!("\u{2022} {item_text}"))
.with_style(UiComponentStyles {
font_size: Some(14.),
font_weight: Some(Weight::Normal),
font_color: Some(text_sub),
..Default::default()
})
.build()
.finish();
items_col = items_col.with_child(bullet);
}
let fg_color = theme.foreground().into_solid();
let subscribe_btn = Hoverable::new(
self.subscribe_panel_mouse_state.clone(),
move |mouse_state| {
let bg = if mouse_state.is_clicked() {
internal_colors::accent_overlay_3(theme)
} else if mouse_state.is_hovered() {
internal_colors::accent_overlay_4(theme)
} else {
theme.accent()
};
let label = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
warpui::elements::Text::new_inline(
"Subscribe",
appearance.ui_font_family(),
14.,
)
.with_color(fg_color)
.with_style(warpui::fonts::Properties {
weight: Weight::Semibold,
style: warpui::fonts::Style::Normal,
})
.with_selectable(false)
.finish(),
)
.finish();
ConstrainedBox::new(
Container::new(label)
.with_horizontal_padding(12.)
.with_background(bg)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish(),
)
.with_height(32.)
.finish()
},
)
.with_cursor(Cursor::PointingHand)
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(FreeUserNoAiSlideAction::UpgradeClicked);
})
.finish();
let card_content = Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(title)
.with_child(
Container::new(items_col.finish())
.with_margin_top(20.)
.finish(),
)
.with_child(Container::new(subscribe_btn).with_margin_top(24.).finish())
.finish();
let card = Container::new(card_content)
.with_uniform_padding(28.)
.with_background(card_bg)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(12.)))
.with_drop_shadow(DropShadow::default())
.finish();
Container::new(Align::new(ConstrainedBox::new(card).with_max_width(420.).finish()).finish())
.with_background(theme.surface_1())
.finish()
}
}
impl Entity for FreeUserNoAiSlide {
type Event = ();
}
impl View for FreeUserNoAiSlide {
fn ui_name() -> &'static str {
"FreeUserNoAiSlide"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let intention = self.model_intention(app);
let agent_price_badge = self.onboarding_state.as_ref(app).agent_price_badge();
let agent_price_badge = agent_price_badge.as_str();
let selected_index = match intention {
OnboardingIntention::AgentDrivenDevelopment => 0,
OnboardingIntention::Terminal => 1,
};
// Wide (right panel visible): greyed-out Next in nav.
// Narrow (right panel hidden): Subscribe in nav (the only CTA visible).
let wide = layout::static_left(
|| self.render_content(appearance, selected_index, false, agent_price_badge),
|| self.render_subscribe_panel(appearance),
);
let narrow = layout::static_left(
|| self.render_content(appearance, selected_index, true, agent_price_badge),
|| self.render_subscribe_panel(appearance),
);
SizeConstraintSwitch::new(
wide,
vec![(
SizeConstraintCondition::WidthLessThan(layout::TWO_COLUMN_MIN_WIDTH),
narrow,
)],
)
.finish()
}
}
impl OnboardingSlide for FreeUserNoAiSlide {
fn on_enter(&mut self, ctx: &mut ViewContext<Self>) {
let intention = self.model_intention(ctx);
if matches!(intention, OnboardingIntention::Terminal) {
self.onboarding_state
.update(ctx, |model, ctx| model.complete(ctx));
}
}
fn on_up(&mut self, ctx: &mut ViewContext<Self>) {
let intention = self.model_intention(ctx);
if matches!(intention, OnboardingIntention::Terminal) {
self.onboarding_state.update(ctx, |model, ctx| {
model.set_intention_agent_driven_development(ctx)
});
ctx.notify();
}
}
fn on_down(&mut self, ctx: &mut ViewContext<Self>) {
let intention = self.model_intention(ctx);
if matches!(intention, OnboardingIntention::AgentDrivenDevelopment) {
self.onboarding_state
.update(ctx, |model, ctx| model.set_intention_terminal(ctx));
ctx.notify();
}
}
}
impl TypedActionView for FreeUserNoAiSlide {
type Action = FreeUserNoAiSlideAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
FreeUserNoAiSlideAction::SelectAgent => {
self.onboarding_state.update(ctx, |model, ctx| {
model.set_intention_agent_driven_development(ctx);
});
ctx.notify();
}
FreeUserNoAiSlideAction::SelectTerminal => {
self.onboarding_state.update(ctx, |model, ctx| {
model.set_intention_terminal(ctx);
});
ctx.notify();
}
FreeUserNoAiSlideAction::BackClicked => {
self.onboarding_state
.update(ctx, |model, ctx| model.back(ctx));
}
FreeUserNoAiSlideAction::NextClicked => {
self.onboarding_state
.update(ctx, |model, ctx| model.complete(ctx));
}
FreeUserNoAiSlideAction::UpgradeClicked => {
send_telemetry_from_ctx!(OnboardingEvent::FreeUserNoAiUpgradeClicked, ctx);
self.onboarding_state
.update(ctx, |model, ctx| model.request_upgrade(ctx));
}
}
}
}
@@ -0,0 +1,582 @@
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 warp_core::features::FeatureFlag;
use warp_core::ui::theme::Fill;
use warp_core::ui::{appearance::Appearance, theme::color::internal_colors, Icon};
use warpui::prelude::Align;
use warpui::{
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},
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
ViewContext,
};
#[derive(Debug, Clone)]
pub enum IntentionSlideAction {
SelectOption { index: usize },
BackClicked,
NextClicked,
}
pub struct IntentionSlide {
onboarding_state: ModelHandle<OnboardingStateModel>,
agent_driven_development_mouse_state: MouseStateHandle,
classic_terminal_mouse_state: MouseStateHandle,
back_button: button::Button,
next_button: button::Button,
scroll_state: ClippedScrollStateHandle,
}
impl IntentionSlide {
pub(crate) fn new(onboarding_state: ModelHandle<OnboardingStateModel>) -> Self {
Self {
onboarding_state,
agent_driven_development_mouse_state: MouseStateHandle::default(),
classic_terminal_mouse_state: MouseStateHandle::default(),
back_button: button::Button::default(),
next_button: button::Button::default(),
scroll_state: ClippedScrollStateHandle::new(),
}
}
fn model_intention(&self, app: &AppContext) -> OnboardingIntention {
*self.onboarding_state.as_ref(app).intention()
}
fn render_content(&self, appearance: &Appearance, selected_index: usize) -> Box<dyn Element> {
let bottom_nav = Align::new(self.render_bottom_nav(appearance, selected_index)).finish();
slide_content::onboarding_slide_content(
vec![
Align::new(self.render_header(appearance)).left().finish(),
Align::new(self.render_options(appearance, selected_index)).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("Welcome to Warp")
.with_style(UiComponentStyles {
font_size: Some(36.),
font_weight: Some(Weight::Medium),
..Default::default()
})
.build()
.finish();
let subtitle = FormattedTextElement::from_str(
"How do you want to work?",
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, selected_index: usize) -> Box<dyn Element> {
let agent_card = self.render_agent_card(
appearance,
selected_index == 0,
self.agent_driven_development_mouse_state.clone(),
);
let terminal_card = self.render_terminal_card(
appearance,
selected_index == 1,
self.classic_terminal_mouse_state.clone(),
);
Container::new(
Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(Container::new(agent_card).with_margin_bottom(12.).finish())
.with_child(terminal_card)
.finish(),
)
.with_margin_top(38.)
.finish()
}
/// Shared chrome for an intention-slide option card. Applies the selected/unselected
/// background + border + rounded corners, wires up hover/click, and emits the
/// `SelectOption` action for the provided `index`.
fn render_card_chrome(
appearance: &Appearance,
is_selected: bool,
index: usize,
mouse_state: MouseStateHandle,
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(IntentionSlideAction::SelectOption { index });
})
.finish()
}
fn render_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 checklist_color = label_color;
let icon_fill = Fill::Solid(label_color);
let header_row = {
let label = appearance
.ui_builder()
.paragraph("Build faster with AI agents")
.with_style(UiComponentStyles {
font_size: Some(16.),
font_weight: Some(Weight::Semibold),
font_color: Some(label_color),
..Default::default()
})
.build()
.finish();
let mut icon_row = Flex::row()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
for (i, icon) in [Icon::Oz, Icon::ClaudeLogo, Icon::OpenAILogo]
.iter()
.enumerate()
{
let el = ConstrainedBox::new(icon.to_warpui_icon(icon_fill).finish())
.with_width(16.)
.with_height(16.)
.finish();
icon_row = if i == 0 {
icon_row.with_child(el)
} else {
icon_row.with_child(Container::new(el).with_margin_left(8.).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(icon_row.finish())
.finish()
};
let description = FormattedTextElement::from_str(
"An agent-first experience with best in class terminal support. Get terminal and agent driven development AI features like:",
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 = {
let items = AI_FEATURES;
// When the agent 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(checklist_color)
};
let mut col = Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Start);
for &item in items {
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(checklist_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, 0, mouse_state, content)
}
fn render_terminal_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("Just use the terminal")
.with_style(UiComponentStyles {
font_size: Some(16.),
font_weight: Some(Weight::Semibold),
font_color: Some(text_color),
..Default::default()
})
.build()
.finish();
let badge = {
let badge_text = appearance
.ui_builder()
.paragraph("No AI features")
.with_style(UiComponentStyles {
font_size: Some(12.),
font_weight: Some(Weight::Semibold),
font_color: Some(text_color),
..Default::default()
})
.build()
.finish();
Container::new(badge_text)
.with_background(internal_colors::fg_overlay_2(theme))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(3.)))
.with_horizontal_padding(4.)
.with_vertical_padding(2.)
.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(
"A modern terminal optimized for speed, context, and control without AI.",
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(header_row)
.with_child(Container::new(description).with_margin_top(12.).finish())
.finish();
Self::render_card_chrome(appearance, is_selected, 1, mouse_state, content)
}
fn render_bottom_nav(
&self,
appearance: &Appearance,
selected_index: usize,
) -> 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(IntentionSlideAction::BackClicked);
})),
..button::Options::default(appearance)
},
},
);
let new_settings_modes = FeatureFlag::OpenWarpNewSettingsModes.is_enabled();
let next_text = if !new_settings_modes && selected_index == 1 {
"Get Warping"
} else {
"Next"
};
let enter = Keystroke::parse("enter").unwrap_or_default();
let next_button = self.next_button.render(
appearance,
button::Params {
content: button::Content::Label(next_text.into()),
theme: &button::themes::Primary,
options: button::Options {
keystroke: Some(enter),
on_click: Some(Box::new(|ctx, _app, _pos| {
ctx.dispatch_typed_action(IntentionSlideAction::NextClicked);
})),
..button::Options::default(appearance)
},
},
);
let is_terminal = selected_index == 1;
let (step_index, step_count) = if new_settings_modes {
if is_terminal {
(0, 4)
} else {
(0, 5)
}
} else {
(1, 4)
};
bottom_nav::onboarding_bottom_nav(
appearance,
step_index,
step_count,
Some(back_button),
Some(next_button),
)
}
/// All onboarding image paths used by the intention slide visual.
pub(crate) const VISUAL_IMAGE_PATHS: &'static [&'static str] = &[
"async/png/onboarding/welcome_agent.png",
"async/png/onboarding/welcome_terminal.png",
];
fn render_visual(&self, appearance: &Appearance, selected_index: usize) -> Box<dyn Element> {
let theme = appearance.theme();
if FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
let path = if selected_index == 1 {
Self::VISUAL_IMAGE_PATHS[1]
} else {
Self::VISUAL_IMAGE_PATHS[0]
};
layout::onboarding_right_panel_with_bg(path, layout::FOREGROUND_LAYOUT_DEFAULT)
} else {
let panel_background = internal_colors::neutral_2(theme);
let neutral = internal_colors::neutral_4(theme);
let neutral_highlight = internal_colors::neutral_6(theme);
let accent = internal_colors::accent(theme);
let visual = if selected_index == 1 {
intention_terminal_visual(
panel_background,
neutral,
neutral_highlight,
accent.into_solid(),
)
} else {
let blue = theme.ansi_fg_blue();
let green = theme.ansi_fg_green();
let yellow = theme.ansi_fg_yellow();
intention_visual(panel_background, neutral, blue, green, yellow)
};
Container::new(visual)
.with_background_color(internal_colors::neutral_1(theme))
.finish()
}
}
}
impl Entity for IntentionSlide {
type Event = ();
}
impl View for IntentionSlide {
fn ui_name() -> &'static str {
"IntentionSlide"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let intention = self.model_intention(app);
let selected_index = match intention {
OnboardingIntention::AgentDrivenDevelopment => 0,
OnboardingIntention::Terminal => 1,
};
// Background is rendered by the parent onboarding view (including background images).
layout::static_left(
|| self.render_content(appearance, selected_index),
|| self.render_visual(appearance, selected_index),
)
}
}
impl IntentionSlide {
fn select_option(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
self.onboarding_state.update(ctx, |model, ctx| match index {
0 => model.set_intention_agent_driven_development(ctx),
1 => model.set_intention_terminal(ctx),
_ => {}
});
ctx.notify();
}
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);
} else {
match model.intention() {
OnboardingIntention::Terminal => {
model.complete(ctx);
}
OnboardingIntention::AgentDrivenDevelopment => {
model.next(ctx);
}
}
}
});
}
}
impl OnboardingSlide for IntentionSlide {
fn on_up(&mut self, ctx: &mut ViewContext<Self>) {
let selected_index: usize = match self.model_intention(ctx) {
OnboardingIntention::AgentDrivenDevelopment => 0,
OnboardingIntention::Terminal => 1,
};
self.select_option(selected_index.saturating_sub(1), ctx);
}
fn on_down(&mut self, ctx: &mut ViewContext<Self>) {
let selected_index: usize = match self.model_intention(ctx) {
OnboardingIntention::AgentDrivenDevelopment => 0,
OnboardingIntention::Terminal => 1,
};
self.select_option((selected_index + 1).min(1), ctx);
}
fn on_enter(&mut self, ctx: &mut ViewContext<Self>) {
self.next(ctx);
}
}
impl TypedActionView for IntentionSlide {
type Action = IntentionSlideAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
IntentionSlideAction::SelectOption { index } => {
self.select_option(*index, ctx);
}
IntentionSlideAction::BackClicked => {
let onboarding_state = self.onboarding_state.clone();
onboarding_state.update(ctx, |model, ctx| {
model.back(ctx);
});
}
IntentionSlideAction::NextClicked => {
self.next(ctx);
}
}
}
}
+217
View File
@@ -0,0 +1,217 @@
use crate::model::OnboardingStateModel;
use crate::OnboardingEvent;
use super::OnboardingSlide;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use ui_components::{button, Component as _, Options as _};
use warp_core::send_telemetry_from_ctx;
use warp_core::ui::{appearance::Appearance, theme::color::internal_colors, Icon};
use warpui::{
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,
};
#[derive(Clone, Debug)]
pub enum IntroSlideEvent {
LoginRequested,
}
#[derive(Clone, Debug)]
pub enum IntroSlideAction {
GetStartedClicked,
LoginClicked,
}
pub struct IntroSlide {
onboarding_state: ModelHandle<OnboardingStateModel>,
get_started_button: button::Button,
shimmering_title_handle: ShimmeringTextStateHandle,
login_mouse_state: MouseStateHandle,
}
impl IntroSlide {
pub(crate) fn new(onboarding_state: ModelHandle<OnboardingStateModel>) -> Self {
Self {
onboarding_state,
get_started_button: button::Button::default(),
shimmering_title_handle: ShimmeringTextStateHandle::new(),
login_mouse_state: MouseStateHandle::default(),
}
}
}
impl Entity for IntroSlide {
type Event = IntroSlideEvent;
}
impl View for IntroSlide {
fn ui_name() -> &'static str {
"IntroSlide"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let content = self.render_centered_content(appearance);
let constrained = ConstrainedBox::new(content).with_max_width(421.).finish();
// Background is rendered by the parent onboarding view (including background images).
let centered = Container::new(Align::new(constrained).finish()).finish();
let sub_text_color = internal_colors::text_sub(theme, theme.background().into_solid());
let ui_builder = appearance.ui_builder();
let disclaimer_styles = UiComponentStyles {
font_color: Some(sub_text_color),
font_size: Some(12.),
..Default::default()
};
let login_row = Flex::row()
.with_child(
ui_builder
.span("Already have an account? ")
.with_style(disclaimer_styles)
.build()
.finish(),
)
.with_child(
ui_builder
.link(
"Log in".into(),
None,
Some(Box::new(|ctx| {
ctx.dispatch_typed_action(IntroSlideAction::LoginClicked);
})),
self.login_mouse_state.clone(),
)
.soft_wrap(false)
.with_style(UiComponentStyles {
font_size: Some(12.),
..Default::default()
})
.build()
.finish(),
)
.finish();
let mut stack = Stack::new();
stack.add_child(centered);
stack.add_positioned_child(
login_row,
OffsetPositioning::offset_from_parent(
vec2f(0., -28.),
ParentOffsetBounds::ParentBySize,
ParentAnchor::BottomMiddle,
ChildAnchor::BottomMiddle,
),
);
stack.finish()
}
}
impl IntroSlide {
fn get_started_clicked(&mut self, ctx: &mut ViewContext<Self>) {
send_telemetry_from_ctx!(OnboardingEvent::GetStartedClicked, ctx);
self.onboarding_state.update(ctx, |model, ctx| {
model.next(ctx);
});
}
}
impl OnboardingSlide for IntroSlide {
fn on_enter(&mut self, ctx: &mut ViewContext<Self>) {
self.get_started_clicked(ctx);
}
}
impl IntroSlide {
fn render_centered_content(&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 base_color: ColorU = internal_colors::fg_overlay_4(theme).into();
let shimmer_color: ColorU = theme.foreground().into();
let title = ShimmeringTextElement::new(
"Welcome to Warp",
appearance.ui_font_family(),
32.,
base_color,
shimmer_color,
ShimmerConfig::default(),
self.shimmering_title_handle.clone(),
)
.finish();
let subtitle_color = internal_colors::text_sub(theme, theme.background().into_solid());
let subtitle = FormattedTextElement::from_str(
"A modern terminal with state of the art agents built in.",
appearance.ui_font_family(),
16.,
)
.with_color(subtitle_color)
.with_alignment(TextAlignment::Center)
.with_line_height_ratio(1.0)
.finish();
let enter = Keystroke::parse("enter").unwrap_or_default();
let get_started_button = self.get_started_button.render(
appearance,
button::Params {
content: button::Content::Label("Get started".into()),
theme: &button::themes::Primary,
options: button::Options {
keystroke: Some(enter),
on_click: Some(Box::new(|ctx, _app, _pos| {
ctx.dispatch_typed_action(IntroSlideAction::GetStartedClicked);
})),
..button::Options::default(appearance)
},
},
);
Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(logo)
.with_child(title)
.with_child(Container::new(subtitle).with_margin_top(12.).finish())
.with_child(
Container::new(get_started_button)
.with_margin_top(24.)
.finish(),
)
.finish()
}
}
impl TypedActionView for IntroSlide {
type Action = IntroSlideAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
IntroSlideAction::GetStartedClicked => {
self.get_started_clicked(ctx);
}
IntroSlideAction::LoginClicked => {
send_telemetry_from_ctx!(OnboardingEvent::WelcomeLoginClicked, ctx);
ctx.emit(IntroSlideEvent::LoginRequested);
}
}
}
}
+380
View File
@@ -0,0 +1,380 @@
use pathfinder_geometry::vector::{vec2f, Vector2F};
use warpui::{
assets::asset_cache::AssetSource,
elements::{
Align, CacheOption, Clipped, ConstrainedBox, Container, CrossAxisAlignment, Empty,
Expanded, Flex, Image, MainAxisSize, ParentElement, Point, Shrinkable,
SizeConstraintCondition, SizeConstraintSwitch, Stack,
},
event::DispatchedEvent,
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext,
SizeConstraint,
};
// 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
// on native builds. Unlike other `async/` assets these are NOT wired up with
// `bundled_or_fetched_asset!`, so they cannot be fetched remotely on web. We can't use
// that macro here because it resolves paths relative to CARGO_MANIFEST_DIR (i.e.
// `crates/onboarding/`), but the assets live under `app/assets/`. Onboarding is not
// shown on web, so this is fine.
// TODO(APP-3934): support the macro outside the app crate
pub const ONBOARDING_BG_PATH: &str = "async/png/onboarding/onboarding_bg.png";
const LEFT_COLUMN_WIDTH: f32 = 580.;
const LEFT_COLUMN_CONTENT_MAX_WIDTH: f32 = 800.;
const MIN_RIGHT_COLUMN_WIDTH: f32 = 540.;
/// The minimum window width at which the two-column layout is shown (left content + right panel).
/// Below this width, `static_left` collapses to a single left-only column.
pub const TWO_COLUMN_MIN_WIDTH: f32 = LEFT_COLUMN_WIDTH + MIN_RIGHT_COLUMN_WIDTH;
/// Creates a two-column layout with a fixed-width left column and flexible right column.
///
/// The left column's *content* is constrained to [`LEFT_COLUMN_CONTENT_MAX_WIDTH`] and is always
/// horizontally centered within the left panel.
///
/// If the available width is too narrow for the right column to have at least
/// [`MIN_RIGHT_COLUMN_WIDTH`], we instead render only the left column and center it.
///
/// # Arguments
/// * `left` - Builder for the element to display in the left column ([`LEFT_COLUMN_WIDTH`] px)
/// * `right` - Builder for the element to display in the right column (flexible width)
///
/// # Returns
/// A `Box<dyn Element>` containing the responsive layout
pub fn static_left(
left: impl Fn() -> Box<dyn Element>,
right: impl FnOnce() -> Box<dyn Element>,
) -> Box<dyn Element> {
let max_width_for_two_columns = LEFT_COLUMN_WIDTH + MIN_RIGHT_COLUMN_WIDTH;
let left_constrained = || {
ConstrainedBox::new(left())
.with_max_width(LEFT_COLUMN_CONTENT_MAX_WIDTH)
.finish()
};
// Narrow layout: show only the left section, centered.
// Use Align instead of a max-sized Flex so we can safely center even when the incoming
// height constraint is unbounded.
let left_only = Align::new(left_constrained()).finish();
// Default layout: fixed-width left + flexible right.
// Use Align instead of a max-sized Flex so we can safely center even when the incoming
// height constraint is unbounded.
let left_centered = Align::new(left_constrained()).finish();
let left_fixed_width = Container::new(
ConstrainedBox::new(left_centered)
.with_width(LEFT_COLUMN_WIDTH)
.finish(),
)
.finish();
let right_flexible = Shrinkable::new(1., Container::new(right()).finish()).finish();
let two_column_layout = Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(left_fixed_width)
.with_child(right_flexible)
.finish(),
)
.finish();
SizeConstraintSwitch::new(
two_column_layout,
vec![(
SizeConstraintCondition::WidthLessThan(max_width_for_two_columns),
left_only,
)],
)
.finish()
}
/// How horizontal space around the foreground image is applied.
#[derive(Clone, Copy)]
pub enum HPadding {
/// Symmetric fixed padding on both sides (in pixels). Used for the default layout.
Fixed(f32),
/// Symmetric padding on both sides scales with panel width.
/// Value is a fraction of panel width per side (e.g. `39. / 640.`).
ProportionalBoth(f32),
/// Left padding scales with panel width; image fills the rest to the right edge.
/// Value is a fraction of panel width (e.g. `39. / 640.`).
ProportionalLeft(f32),
/// Right padding scales with panel width; image fills the rest to the left edge.
/// `ratio` is a fraction of panel width (e.g. `69. / 640.`).
/// `left_offset` is a fixed pixel adjustment to the image's left edge; use a negative
/// value to shift left when the asset has built-in visual padding on that side.
ProportionalRight { ratio: f32, left_offset: f32 },
}
/// How the vertical top offset for the foreground image is determined.
#[derive(Clone, Copy)]
pub enum TopMode {
/// Fixed fraction of panel height: `top = panel_height × ratio`.
Ratio(f32),
/// Dynamic: centers a specific fraction of the image's rendered height in the panel.
///
/// For `cover()` width-limited: `image_height = panel_width × (1 2×h_ratio) × inv_aspect`
/// `top = (panel_height × 0.5 image_height × frac).max(0.)`
///
/// Adapts to both panel dimensions so the chosen image fraction stays
/// centered even when the panel size changes or the image overflows at the bottom.
CenterFraction {
/// Each sides proportion of panel width used for horizontal spacing.
h_ratio: f32,
/// `natural_height / natural_width` of the image asset.
inv_aspect: f32,
/// The fraction of image height to center in the panel (e.g. `0.25`).
frac: f32,
},
}
/// How the foreground image is fitted within its slot.
#[derive(Clone, Copy)]
pub enum ForegroundFit {
/// `cover().top_aligned()`: image fills the slot width; overflows at the bottom.
CoverTopAligned,
/// `contain().top_aligned()`: full image visible, centered horizontally, pinned to top.
ContainTopAligned,
/// `contain().right_aligned()`: full image visible, right edge flush, space on the left.
ContainRightAligned,
}
/// Sizing and positioning for a foreground image on the onboarding right panel.
///
/// `top_mode` controls vertical positioning; `h_padding` controls horizontal padding.
#[derive(Clone, Copy)]
pub struct ForegroundLayout {
/// Vertical positioning mode.
pub top_mode: TopMode,
/// Horizontal padding mode.
pub h_padding: HPadding,
/// Image fit and alignment within its slot.
pub fit: ForegroundFit,
}
/// Layout for welcome/intention/theme-picker slides.
/// Proportional symmetric horizontal padding; cover() fills the slot width.
/// Dynamic top: centers the 25%-from-top image point in the panel, adapting to panel size.
pub const FOREGROUND_LAYOUT_DEFAULT: ForegroundLayout = ForegroundLayout {
top_mode: TopMode::CenterFraction {
h_ratio: 39. / 640.,
inv_aspect: 612. / 561.,
frac: 0.25,
},
h_padding: HPadding::ProportionalBoth(39. / 640.),
fit: ForegroundFit::CoverTopAligned,
};
/// Layout for customize slides.
/// Proportional left padding; image fills to the right edge via cover.
/// We cover because the most important part is the top left of the image.
pub const FOREGROUND_LAYOUT_WIDE: ForegroundLayout = ForegroundLayout {
top_mode: TopMode::Ratio(118. / 800.),
h_padding: HPadding::ProportionalLeft(39. / 640.),
fit: ForegroundFit::CoverTopAligned,
};
/// Layout for code-review customize images.
/// Proportional right padding; portrait image fills to the left edge and overflows bottom.
/// We cover because the most important part is the top right of the image.
pub const FOREGROUND_LAYOUT_CODE_REVIEW: ForegroundLayout = ForegroundLayout {
top_mode: TopMode::Ratio(118. / 800.),
h_padding: HPadding::ProportionalRight {
ratio: 69. / 640.,
left_offset: -16.,
},
fit: ForegroundFit::CoverTopAligned,
};
/// Layout for third-party slides.
/// No horizontal padding; contain() shows the full image; right-aligned so any
/// leftover horizontal space appears on the left.
/// We contain because the most important part is the bottom of the image which needs to be visible.
pub const FOREGROUND_LAYOUT_THIRD_PARTY: ForegroundLayout = ForegroundLayout {
top_mode: TopMode::Ratio(118. / 800.),
h_padding: HPadding::Fixed(0.),
fit: ForegroundFit::ContainRightAligned,
};
/// Wraps an image slot with a dynamic top offset that keeps a chosen fraction
/// of the image height centered in the panel.
///
/// The image slot is given the remaining panel height after the computed top offset.
/// The slot's paint overflows downward (visible up to the panel `Clipped` boundary).
struct CenterFractionTopWrapper {
h_ratio: f32,
inv_aspect: f32,
frac: f32,
inner: Box<dyn Element>,
panel_size: Option<Vector2F>,
top: f32,
origin: Option<Point>,
}
impl CenterFractionTopWrapper {
fn new(h_ratio: f32, inv_aspect: f32, frac: f32, inner: Box<dyn Element>) -> Self {
Self {
h_ratio,
inv_aspect,
frac,
inner,
panel_size: None,
top: 0.,
origin: None,
}
}
}
impl Element for CenterFractionTopWrapper {
fn layout(
&mut self,
constraint: SizeConstraint,
ctx: &mut LayoutContext,
app: &AppContext,
) -> Vector2F {
let panel = constraint.max;
self.panel_size = Some(panel);
// image_height for cover() width-limited: slot_width × inv_aspect
let slot_w = panel.x() * (1. - 2. * self.h_ratio);
let image_h = slot_w * self.inv_aspect;
self.top = (panel.y() * 0.5 - image_h * self.frac).max(0.);
let slot_h = (panel.y() - self.top).max(1.);
let slot = vec2f(panel.x(), slot_h);
self.inner.layout(SizeConstraint::new(slot, slot), ctx, app);
panel
}
fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
self.inner.after_layout(ctx, app);
}
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
self.inner.paint(origin + vec2f(0., self.top), ctx, app);
}
fn dispatch_event(
&mut self,
_event: &DispatchedEvent,
_ctx: &mut EventContext,
_app: &AppContext,
) -> bool {
false
}
fn size(&self) -> Option<Vector2F> {
self.panel_size
}
fn origin(&self) -> Option<Point> {
self.origin
}
}
/// Wraps a slide's foreground visual with the shared onboarding background image.
///
/// For `TopMode::Ratio`: uses a `Flex::column` with a proportional `Expanded` top spacer.
/// For `TopMode::CenterFraction`: uses `CenterFractionTopWrapper` which computes the
/// top offset dynamically at layout time based on the actual panel dimensions.
pub fn onboarding_right_panel_with_bg(
path: &'static str,
layout: ForegroundLayout,
) -> Box<dyn Element> {
let background = Image::new(
AssetSource::Bundled {
path: ONBOARDING_BG_PATH,
},
CacheOption::Original,
)
.stretch()
.finish();
let image = match layout.fit {
ForegroundFit::CoverTopAligned => {
Image::new(AssetSource::Bundled { path }, CacheOption::Original)
.cover()
.top_aligned()
.finish()
}
ForegroundFit::ContainTopAligned => {
Image::new(AssetSource::Bundled { path }, CacheOption::Original)
.contain()
.top_aligned()
.finish()
}
ForegroundFit::ContainRightAligned => {
Image::new(AssetSource::Bundled { path }, CacheOption::Original)
.contain()
.right_aligned()
.finish()
}
};
// Apply horizontal padding: fixed pixels (DEFAULT) or proportional to panel width.
let image_slot: Box<dyn Element> = match layout.h_padding {
HPadding::Fixed(px) => Container::new(image)
.with_padding_left(px)
.with_padding_right(px)
.finish(),
HPadding::ProportionalBoth(ratio) => Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(Box::new(Expanded::new(ratio, Box::new(Empty::new()))))
.with_child(Box::new(Expanded::new(1. - 2. * ratio, image)))
.with_child(Box::new(Expanded::new(ratio, Box::new(Empty::new()))))
.finish(),
HPadding::ProportionalLeft(ratio) => Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(Box::new(Expanded::new(ratio, Box::new(Empty::new()))))
.with_child(Box::new(Expanded::new(1. - ratio, image)))
.finish(),
HPadding::ProportionalRight { ratio, left_offset } => {
let adjusted = if left_offset != 0. {
Container::new(image)
.with_padding_left(left_offset)
.finish()
} else {
image
};
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(Box::new(Expanded::new(1. - ratio, adjusted)))
.with_child(Box::new(Expanded::new(ratio, Box::new(Empty::new()))))
.finish()
}
};
// Build the foreground with either a fixed or dynamic top offset.
let foreground: Box<dyn Element> = match layout.top_mode {
TopMode::Ratio(ratio) => {
let remaining = 1. - ratio;
Flex::column()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(Box::new(Expanded::new(ratio, Box::new(Empty::new()))))
.with_child(Box::new(Expanded::new(remaining, image_slot)))
.finish()
}
TopMode::CenterFraction {
h_ratio,
inv_aspect,
frac,
} => Box::new(CenterFractionTopWrapper::new(
h_ratio, inv_aspect, frac, image_slot,
)),
};
let mut stack = Stack::new();
stack.extend(Some(background));
stack.extend(Some(foreground));
Clipped::new(stack.finish()).finish()
}
+28
View File
@@ -0,0 +1,28 @@
mod agent_slide;
mod bottom_nav;
mod customize_slide;
mod free_user_no_ai_slide;
mod intention_slide;
mod intro_slide;
pub mod layout;
mod onboarding_slide;
mod progress_dots;
mod project_slide;
pub mod slide_content;
mod theme_picker_slide;
mod third_party_slide;
mod toggle_card;
mod two_line_button;
pub use agent_slide::{
AgentAutonomy, AgentDevelopmentSettings, AgentSlide, AgentSlideEvent, OnboardingModelInfo,
};
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;
pub use project_slide::{ProjectOnboardingSettings, ProjectSlide};
pub use theme_picker_slide::{ThemePickerSlide, ThemePickerSlideEvent};
pub use third_party_slide::ThirdPartySlide;
@@ -0,0 +1,12 @@
use warpui::{View, ViewContext};
pub trait OnboardingSlide: View {
fn on_up(&mut self, _ctx: &mut ViewContext<Self>) {}
fn on_down(&mut self, _ctx: &mut ViewContext<Self>) {}
fn on_left(&mut self, _ctx: &mut ViewContext<Self>) {}
fn on_right(&mut self, _ctx: &mut ViewContext<Self>) {}
fn on_tab(&mut self, _ctx: &mut ViewContext<Self>) {}
fn on_enter(&mut self, _ctx: &mut ViewContext<Self>) {}
fn on_cmd_or_ctrl_enter(&mut self, _ctx: &mut ViewContext<Self>) {}
fn on_escape(&mut self, _ctx: &mut ViewContext<Self>) {}
}
@@ -0,0 +1,42 @@
use warp_core::ui::{appearance::Appearance, theme::color::internal_colors};
use warpui::{
elements::{
ConstrainedBox, Container, CornerRadius, Empty, Flex, MainAxisSize, ParentElement, Radius,
},
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> {
const DOT_RADIUS: f32 = 4.;
const DOT_DIAMETER: f32 = DOT_RADIUS * 2.;
const DOT_SPACING: f32 = 8.;
let theme = appearance.theme();
let neutral = internal_colors::neutral_4(theme);
let accent = internal_colors::accent(theme).into_solid();
let dots = (0..n)
.map(|i| {
let color = if i == k { accent } else { neutral };
Container::new(
ConstrainedBox::new(
Container::new(Empty::new().finish())
.with_background_color(color)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(DOT_RADIUS)))
.finish(),
)
.with_width(DOT_DIAMETER)
.with_height(DOT_DIAMETER)
.finish(),
)
.with_margin_left(if i == 0 { 0. } else { DOT_SPACING })
.finish()
})
.collect::<Vec<_>>();
Flex::row()
.with_main_axis_size(MainAxisSize::Min)
.with_children(dots)
.finish()
}
@@ -0,0 +1,565 @@
use crate::model::OnboardingStateModel;
use crate::slides::{bottom_nav, layout, slide_content};
use crate::telemetry::OnboardingEvent;
use crate::visuals::project_visual;
use ui_components::{button, keyboard_shortcut, Component as _, Options as _};
use warp_core::send_telemetry_from_ctx;
use warp_core::ui::{
appearance::Appearance, color::coloru_with_opacity, theme::color::internal_colors, Icon,
};
use warpui::prelude::{MainAxisAlignment, MainAxisSize, Vector2F};
use warpui::ui_components::button::{ButtonVariant, TextAndIcon, TextAndIconAlignment};
use warpui::{
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 super::OnboardingSlide;
const LEFT_COLUMN_W: f32 = 428.;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum ProjectOnboardingSettings {
#[default]
NoProject,
Project {
selected_local_folder: String,
initialize_projects_automatically: bool,
},
}
impl ProjectOnboardingSettings {
pub fn from_path(path: Option<String>) -> Self {
match path {
None => ProjectOnboardingSettings::NoProject,
Some(path) => ProjectOnboardingSettings::Project {
selected_local_folder: path,
initialize_projects_automatically: true,
},
}
}
}
#[derive(Debug, Clone)]
pub enum ProjectSlideAction {
BackClicked,
NextClicked,
SkipClicked,
OpenLocalFolderClicked,
LocalFolderSelected(Result<String, FilePickerError>),
ToggleInitializeProjectsAutomatically,
}
pub struct ProjectSlide {
onboarding_state: ModelHandle<OnboardingStateModel>,
open_folder_mouse_state: MouseStateHandle,
back_button: button::Button,
next_button: button::Button,
initialize_projects_automatically_mouse_state: MouseStateHandle,
scroll_state: ClippedScrollStateHandle,
}
impl ProjectSlide {
pub(crate) fn new(onboarding_state: ModelHandle<OnboardingStateModel>) -> Self {
Self {
onboarding_state,
open_folder_mouse_state: MouseStateHandle::default(),
back_button: button::Button::default(),
next_button: button::Button::default(),
initialize_projects_automatically_mouse_state: MouseStateHandle::default(),
scroll_state: ClippedScrollStateHandle::new(),
}
}
fn project_settings<'a>(&self, app: &'a AppContext) -> &'a ProjectOnboardingSettings {
self.onboarding_state.as_ref(app).project_settings()
}
fn render_content(
&self,
appearance: &Appearance,
settings: &ProjectOnboardingSettings,
agent_modality_enabled: bool,
) -> Box<dyn Element> {
let mut children = vec![
Align::new(self.render_header(appearance)).finish(),
Align::new(self.render_open_folder_button(appearance, settings)).finish(),
];
// Only show the "Initialize project automatically" checkbox when AgentView is NOT enabled.
// When AgentView is enabled, initialization is handled differently through the callout flow.
if !agent_modality_enabled {
if let ProjectOnboardingSettings::Project {
initialize_projects_automatically,
..
} = settings
{
children.push(
Align::new(
self.render_project_options(*initialize_projects_automatically, appearance),
)
.finish(),
);
}
}
let bottom_nav = Align::new(self.render_bottom_nav(appearance, settings)).finish();
slide_content::onboarding_slide_content(
children,
bottom_nav,
self.scroll_state.clone(),
appearance,
)
}
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
let title = appearance
.ui_builder()
.paragraph("Open a project")
.with_style(UiComponentStyles {
font_size: Some(36.),
font_weight: Some(Weight::Medium),
..Default::default()
})
.build()
.finish();
let subtitle = appearance
.ui_builder()
.paragraph("Set up a project to optimize it for coding in Warp.")
.with_style(UiComponentStyles {
font_size: Some(20.),
font_weight: Some(Weight::Normal),
font_color: Some(internal_colors::text_sub(
appearance.theme(),
appearance.theme().background(),
)),
..Default::default()
})
.build()
.finish();
ConstrainedBox::new(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(title)
.with_child(Container::new(subtitle).with_margin_top(8.).finish())
.finish(),
)
.with_max_width(LEFT_COLUMN_W)
.finish()
}
fn render_open_folder_button(
&self,
appearance: &Appearance,
settings: &ProjectOnboardingSettings,
) -> Box<dyn Element> {
// Match the intention/agent layout: a wide button within the left column.
let theme = appearance.theme();
let (label, variant) = match settings {
ProjectOnboardingSettings::Project {
selected_local_folder,
..
} => (
TextAndIcon::new(
TextAndIconAlignment::IconFirst,
selected_local_folder.to_owned(),
Icon::Folder.to_warpui_icon(theme.foreground()),
MainAxisSize::Max,
MainAxisAlignment::Center,
Vector2F::new(16., 16.),
)
.with_inner_padding(8.),
ButtonVariant::Secondary,
),
ProjectOnboardingSettings::NoProject => {
let enter = Keystroke::parse("enter").unwrap_or_default();
let text_color = theme.foreground().into();
let border_color = coloru_with_opacity(text_color, 60);
let shortcut = keyboard_shortcut::KeyboardShortcut.render(
appearance,
keyboard_shortcut::Params {
keystroke: enter,
options: keyboard_shortcut::Options {
font_color: Some(text_color),
background: None,
border_fill: Some(border_color.into()),
sizing: keyboard_shortcut::Sizing {
font_size: 12.,
padding: 2.,
},
},
},
);
let folder_icon =
ConstrainedBox::new(Icon::Folder.to_warpui_icon(theme.foreground()).finish())
.with_width(16.)
.with_height(16.)
.finish();
let folder_text = Container::new(
appearance
.ui_builder()
.paragraph("Open local folder")
.with_style(UiComponentStyles {
font_color: Some(text_color),
..Default::default()
})
.build()
.finish(),
)
.with_margin_left(8.)
.finish();
let enter_shortcut = Container::new(shortcut).with_margin_left(8.).finish();
let label = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_children([folder_icon, folder_text, enter_shortcut])
.finish();
let button = appearance
.ui_builder()
.button(ButtonVariant::Accent, self.open_folder_mouse_state.clone())
.with_custom_label(label)
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(ProjectSlideAction::OpenLocalFolderClicked)
})
.finish();
return Container::new(
ConstrainedBox::new(button)
.with_width(LEFT_COLUMN_W)
.finish(),
)
.with_margin_top(24.)
.finish();
}
};
let button = appearance
.ui_builder()
.button(variant, self.open_folder_mouse_state.clone())
.with_text_and_icon_label(label)
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(ProjectSlideAction::OpenLocalFolderClicked)
})
.finish();
Container::new(
ConstrainedBox::new(button)
.with_width(LEFT_COLUMN_W)
.finish(),
)
.with_margin_top(24.)
.finish()
}
fn render_bottom_nav(
&self,
appearance: &Appearance,
settings: &ProjectOnboardingSettings,
) -> 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(ProjectSlideAction::BackClicked);
})),
..button::Options::default(appearance)
},
},
);
let theme_picker_last =
warp_core::features::FeatureFlag::OpenWarpNewSettingsModes.is_enabled();
let (label, keystroke, action) = match settings {
ProjectOnboardingSettings::Project { .. } => (
if theme_picker_last {
"Next"
} else {
"Get Warping"
},
Keystroke::parse("enter").unwrap_or_default(),
ProjectSlideAction::NextClicked,
),
ProjectOnboardingSettings::NoProject => (
"Skip",
Keystroke::parse("cmdorctrl-enter").unwrap_or_default(),
ProjectSlideAction::SkipClicked,
),
};
let next_button = self.next_button.render(
appearance,
button::Params {
content: button::Content::Label(label.into()),
theme: &button::themes::Primary,
options: button::Options {
keystroke: Some(keystroke),
on_click: Some(Box::new(move |ctx, _app, _pos| {
ctx.dispatch_typed_action(action.clone());
})),
..button::Options::default(appearance)
},
},
);
// The project slide is unreachable in the new flow (ThirdParty → ThemePicker),
// so only the legacy step counts apply.
let (step_index, step_count) = (3, 4);
bottom_nav::onboarding_bottom_nav(
appearance,
step_index,
step_count,
Some(back_button),
Some(next_button),
)
}
fn render_option(
&self,
appearance: &Appearance,
mouse_state: MouseStateHandle,
checked: bool,
title: &'static str,
description: &'static str,
action: ProjectSlideAction,
) -> Box<dyn Element> {
let theme = appearance.theme();
let action = action.clone();
let checkbox = appearance
.ui_builder()
.checkbox(mouse_state, Some(12.))
.check(checked)
.build()
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(action.clone()))
.finish();
let title = appearance
.ui_builder()
.wrappable_text(title, true)
.with_style(UiComponentStyles {
font_size: Some(12.),
font_weight: Some(Weight::Normal),
font_color: Some(theme.sub_text_color(theme.background()).into_solid()),
..Default::default()
})
.build()
.finish();
let description = appearance
.ui_builder()
.wrappable_text(description, true)
.with_style(UiComponentStyles {
font_size: Some(12.),
font_weight: Some(Weight::Normal),
font_color: Some(theme.disabled_text_color(theme.background()).into_solid()),
..Default::default()
})
.build()
.finish();
let text_col = Container::new(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(title)
.with_child(Container::new(description).with_margin_top(4.).finish())
.finish(),
)
.with_uniform_padding(3.0)
.finish();
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(checkbox)
.with_child(Shrinkable::new(1., text_col).finish())
.finish()
}
fn render_project_options(
&self,
initialize_projects_automatically: bool,
appearance: &Appearance,
) -> Box<dyn Element> {
let initialize = self.render_option(
appearance,
self.initialize_projects_automatically_mouse_state.clone(),
initialize_projects_automatically,
"Initialize project automatically",
"Prepares the project environment, builds an index of your code, and generates project rules—giving the agent deeper understanding and better performance.",
ProjectSlideAction::ToggleInitializeProjectsAutomatically,
);
// Keep this aligned with the folder button width.
ConstrainedBox::new(Container::new(initialize).with_margin_top(16.).finish())
.with_width(LEFT_COLUMN_W)
.finish()
}
fn render_visual(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let panel_background = internal_colors::neutral_2(theme);
let pill_color = internal_colors::fg_overlay_1(theme).into_solid();
let center_icon_color = internal_colors::neutral_5(theme);
let side_icon_color = internal_colors::neutral_4(theme);
Container::new(project_visual(
panel_background,
pill_color,
center_icon_color,
side_icon_color,
))
.with_background_color(internal_colors::neutral_1(theme))
.finish()
}
}
impl Entity for ProjectSlide {
type Event = ();
}
impl View for ProjectSlide {
fn ui_name() -> &'static str {
"ProjectSlide"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let settings = self.project_settings(app);
let agent_modality_enabled = self.onboarding_state.as_ref(app).agent_modality_enabled();
layout::static_left(
|| self.render_content(appearance, settings, agent_modality_enabled),
|| self.render_visual(appearance),
)
}
}
impl ProjectSlide {
fn open_local_folder(&mut self, ctx: &mut ViewContext<Self>) {
send_telemetry_from_ctx!(OnboardingEvent::FolderSelectionStarted, ctx);
ctx.open_file_picker(
|result, ctx| {
if let Some(path_result) = result.map(|paths| paths.into_iter().next()).transpose()
{
ctx.dispatch_typed_action(&ProjectSlideAction::LocalFolderSelected(
path_result,
));
}
},
FilePickerConfiguration::new().folders_only(),
);
}
fn next(&mut self, ctx: &mut ViewContext<Self>) {
if !matches!(
self.project_settings(ctx),
ProjectOnboardingSettings::Project { .. }
) {
return;
}
self.onboarding_state.update(ctx, |model, ctx| {
if warp_core::features::FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
model.next(ctx);
} else {
model.complete(ctx);
}
});
}
fn skip(&mut self, ctx: &mut ViewContext<Self>) {
self.onboarding_state.update(ctx, |model, ctx| {
model.set_project_selected_local_folder(None, ctx);
if warp_core::features::FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
model.next(ctx);
} else {
model.complete(ctx);
}
});
}
}
impl OnboardingSlide for ProjectSlide {
fn on_enter(&mut self, ctx: &mut ViewContext<Self>) {
match self.project_settings(ctx) {
ProjectOnboardingSettings::NoProject => self.open_local_folder(ctx),
ProjectOnboardingSettings::Project { .. } => self.next(ctx),
}
}
fn on_cmd_or_ctrl_enter(&mut self, ctx: &mut ViewContext<Self>) {
if matches!(
self.project_settings(ctx),
ProjectOnboardingSettings::NoProject
) {
self.skip(ctx);
}
}
}
impl TypedActionView for ProjectSlide {
type Action = ProjectSlideAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
ProjectSlideAction::BackClicked => {
let onboarding_state = self.onboarding_state.clone();
onboarding_state.update(ctx, |model, ctx| {
model.back(ctx);
});
}
ProjectSlideAction::NextClicked => {
self.next(ctx);
}
ProjectSlideAction::SkipClicked => {
self.skip(ctx);
}
ProjectSlideAction::OpenLocalFolderClicked => {
self.open_local_folder(ctx);
}
ProjectSlideAction::LocalFolderSelected(result) => match result {
Ok(path) => {
let onboarding_state = self.onboarding_state.clone();
onboarding_state.update(ctx, |model, ctx| {
model.set_project_selected_local_folder(Some(path.clone()), ctx);
});
ctx.notify();
}
Err(err) => {
log::warn!("File picker error during onboarding: {err}");
}
},
ProjectSlideAction::ToggleInitializeProjectsAutomatically => {
let onboarding_state = self.onboarding_state.clone();
onboarding_state.update(ctx, |model, ctx| {
model.toggle_project_initialize_projects_automatically(ctx);
});
ctx.notify();
}
}
}
}
@@ -0,0 +1,73 @@
use warp_core::ui::appearance::Appearance;
use warpui::{
elements::{
Align, ClippedScrollStateHandle, ClippedScrollable, Container, CrossAxisAlignment, Flex,
MainAxisSize, ParentElement, ScrollbarWidth, Shrinkable,
},
Element,
};
pub fn onboarding_slide_content(
children: Vec<Box<dyn Element>>,
bottom_nav: Box<dyn Element>,
scroll_state: ClippedScrollStateHandle,
appearance: &Appearance,
) -> Box<dyn Element> {
const PADDING: f32 = 64.;
// Build the content column with its natural (minimum) height.
let mut content_column = Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch);
for child in children {
content_column = content_column.with_child(child);
}
// Apply right padding inside the scrollable so the scrollbar sits at the
// outer edge of the slide rather than overlapping the content.
let padded_content = Container::new(content_column.finish())
.with_padding_right(PADDING)
.finish();
// Wrap the content in Align so it is centered within the visible area
// when there is enough space.
let centered_content = Align::new(padded_content).finish();
let theme = appearance.theme();
// Create a scrollable content area using vertical_centered so the child's
// min-height matches the visible height (enabling Align to center).
let scrollable = ClippedScrollable::vertical_centered(
scroll_state,
centered_content,
ScrollbarWidth::Auto,
theme.disabled_text_color(theme.background()).into(),
theme.main_text_color(theme.background()).into(),
theme.background().into(),
)
.with_overlayed_scrollbar()
.finish();
// Outer layout: scrollable content takes remaining space, bottom nav
// is always visible at the bottom.
let outer = Flex::column()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(Shrinkable::new(1., scrollable).finish())
.with_child(
Container::new(bottom_nav)
.with_margin_top(24.)
.with_padding_right(PADDING)
.finish(),
)
.finish();
// Left/top/bottom padding on the outer container; right padding is handled
// 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_left(PADDING)
.finish()
}
@@ -0,0 +1,734 @@
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 pathfinder_color::ColorU;
use ui_components::{button, Component as _, Options as _};
use warp_core::features::FeatureFlag;
use warp_core::send_telemetry_from_ctx;
use warp_core::ui::{appearance::Appearance, theme::color::internal_colors, theme::WarpTheme};
use warpui::{
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,
};
#[derive(Debug, Clone)]
pub enum ThemePickerSlideEvent {
ThemeSelected {
theme_name: String,
},
SyncWithOsToggled {
enabled: bool,
},
/// Emitted when the user clicks the "Privacy Settings" link on the terminal
/// intention theme slide. The parent orchestrator is expected to open the
/// privacy settings (e.g. via a LoginSlideView in privacy-only mode).
PrivacySettingsRequested,
}
#[derive(Debug, Clone)]
pub enum ThemePickerSlideAction {
SelectTheme {
index: usize,
},
ToggleSyncWithOs,
BackClicked,
NextClicked,
/// Dispatched when the user clicks the "Privacy Settings" link in the
/// terminal-intention disclaimer block below the theme options.
PrivacySettingsClicked,
}
const TOS_URL: &str = "https://www.warp.dev/terms-of-service";
#[derive(Debug, Clone)]
struct ThemeOption {
theme: WarpTheme,
mouse_state: MouseStateHandle,
}
pub struct ThemePickerSlide {
onboarding_state: ModelHandle<OnboardingStateModel>,
theme_options: [ThemeOption; 4],
selected_theme_index: usize,
sync_with_os: bool,
sync_with_os_mouse: MouseStateHandle,
tos_mouse_state: MouseStateHandle,
privacy_settings_mouse_state: MouseStateHandle,
back_button: button::Button,
next_button: button::Button,
scroll_state: ClippedScrollStateHandle,
}
impl ThemePickerSlide {
pub(crate) fn new(
themes: [WarpTheme; 4],
onboarding_state: ModelHandle<OnboardingStateModel>,
ctx: &mut ViewContext<Self>,
) -> Self {
let theme_options = themes.map(|theme| ThemeOption {
theme,
mouse_state: MouseStateHandle::default(),
});
ctx.subscribe_to_model(&onboarding_state, |_me, _model, event, ctx| {
if matches!(event, OnboardingStateEvent::IntentionChanged) {
ctx.notify();
}
});
let appearance = Appearance::as_ref(ctx);
let current_theme_name = appearance.theme().name();
let selected_theme_index = current_theme_name
.as_ref()
.and_then(|name| {
theme_options
.iter()
.position(|option| option.theme.name().as_ref() == Some(name))
})
.unwrap_or_else(|| {
// If the current appearance theme isn't one of the provided choices, reset it to
// the first option so our selection and the rendered theme match.
Appearance::handle(ctx).update(ctx, |appearance, ctx| {
appearance.set_theme(theme_options[0].theme.clone(), ctx);
});
0
});
Self {
onboarding_state,
theme_options,
selected_theme_index,
sync_with_os: false,
sync_with_os_mouse: MouseStateHandle::default(),
tos_mouse_state: MouseStateHandle::default(),
privacy_settings_mouse_state: MouseStateHandle::default(),
back_button: button::Button::default(),
next_button: button::Button::default(),
scroll_state: ClippedScrollStateHandle::new(),
}
}
fn theme_display_name(&self, index: usize) -> String {
self.theme_options
.get(index)
.and_then(|option| option.theme.name())
.unwrap_or_else(|| format!("Theme {}", index + 1))
}
fn render_theme_picker_content(
&self,
appearance: &Appearance,
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());
let bottom_nav = self.render_bottom_nav(appearance, app);
let theme_options = self.render_theme_options(appearance, &selected_theme);
// Apply a semi-transparent overlay to visually disable the theme options
// when the "Sync with OS" checkbox is checked.
let theme_options_section: Box<dyn Element> = if self.sync_with_os {
let bg = appearance.theme().background().into_solid();
let overlay_color = ColorU::new(bg.r, bg.g, bg.b, 128);
Container::new(theme_options)
.with_foreground_overlay(overlay_color)
.finish()
} else {
theme_options
};
let mut content = vec![self.render_header_text(appearance), theme_options_section];
if FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
content.push(self.render_sync_with_os_section(appearance));
}
// Add the Privacy Settings / Terms of Service disclaimer block below the
// theme options when the user has selected the terminal intention and
// won't hit the login slide afterwards. The terminal-intent flow skips
// the login slide (which surfaces the same links) unless Warp Drive is
// enabled — in that case the login slide will still run after the theme
// step and show the disclaimer, so duplicating it here is unnecessary.
let state = self.onboarding_state.as_ref(app);
let is_terminal = matches!(state.intention(), OnboardingIntention::Terminal);
let warp_drive_enabled = state.ui_customization().show_warp_drive;
if is_terminal && !warp_drive_enabled && FeatureFlag::OpenWarpNewSettingsModes.is_enabled()
{
content.push(self.render_disclaimer_section(appearance));
}
slide_content::onboarding_slide_content(
content,
bottom_nav,
self.scroll_state.clone(),
appearance,
)
}
fn render_header_text(&self, appearance: &Appearance) -> Box<dyn Element> {
let title = appearance
.ui_builder()
.paragraph("Choose a theme")
.with_style(UiComponentStyles {
font_size: Some(36.),
font_weight: Some(Weight::Medium),
..Default::default()
})
.build()
.finish();
let subtitle = FormattedTextElement::from_str(
"Click or use arrow keys to select, Enter to confirm.",
appearance.ui_font_family(),
16.,
)
.with_color(internal_colors::text_sub(
appearance.theme(),
appearance.theme().background().into_solid(),
))
.with_weight(Weight::Normal)
.with_alignment(TextAlignment::Left)
.with_line_height_ratio(1.0)
.finish();
Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(title)
.with_child(Container::new(subtitle).with_margin_top(16.).finish())
.finish()
}
fn render_theme_options(
&self,
appearance: &Appearance,
chrome_theme: &WarpTheme,
) -> Box<dyn Element> {
let options = (0..self.theme_options.len())
.map(|index| {
let theme_name = self.theme_display_name(index);
let option = &self.theme_options[index];
self.render_theme_option(
appearance,
chrome_theme,
index,
theme_name,
&option.theme,
option.mouse_state.clone(),
!self.sync_with_os,
)
})
.collect::<Vec<_>>();
Container::new(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_children(options)
.finish(),
)
.with_margin_top(40.)
.finish()
}
fn render_bottom_nav(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let back_button = self.back_button.render(
appearance,
button::Params {
content: button::Content::Label("Back".into()),
theme: &button::themes::Naked,
options: button::Options {
on_click: Some(Box::new(|ctx, _app, _pos| {
ctx.dispatch_typed_action(ThemePickerSlideAction::BackClicked);
})),
..button::Options::default(appearance)
},
},
);
let theme_picker_last = FeatureFlag::OpenWarpNewSettingsModes.is_enabled();
let next_label = if theme_picker_last {
"Get Warping"
} else {
"Next"
};
let enter = Keystroke::parse("enter").unwrap_or_default();
let next_button = self.next_button.render(
appearance,
button::Params {
content: button::Content::Label(next_label.into()),
theme: &button::themes::Primary,
options: button::Options {
keystroke: Some(enter),
on_click: Some(Box::new(|ctx, _app, _pos| {
ctx.dispatch_typed_action(ThemePickerSlideAction::NextClicked);
})),
..button::Options::default(appearance)
},
},
);
let (step_index, step_count) = if theme_picker_last {
let is_terminal = matches!(
self.onboarding_state.as_ref(app).intention(),
OnboardingIntention::Terminal
);
if is_terminal {
(3, 4)
} else {
(4, 5)
}
} else {
(0, 4)
};
bottom_nav::onboarding_bottom_nav(
appearance,
step_index,
step_count,
Some(back_button),
Some(next_button),
)
}
#[allow(clippy::too_many_arguments)]
fn render_theme_option(
&self,
appearance: &Appearance,
chrome_theme: &WarpTheme,
index: usize,
theme_name: String,
option_theme: &WarpTheme,
mouse_state: MouseStateHandle,
interactive: bool,
) -> Box<dyn Element> {
const SWATCH_RADIUS_PX: f32 = 11.0;
const SWATCH_DIAMETER_PX: f32 = SWATCH_RADIUS_PX * 2.0;
let theme_name_owned = theme_name;
let is_selected = !self.sync_with_os && self.selected_theme_index == index;
let background = if is_selected {
internal_colors::accent_bg(chrome_theme)
} else {
chrome_theme.surface_2()
};
let border_color = if is_selected {
chrome_theme.accent()
} else {
chrome_theme.surface_overlay_1()
};
// Choose text color based on the actual background fill.
let text_color = chrome_theme.main_text_color(background);
// Only the swatches use the *option* theme.
let swatch_colors = [
option_theme.ansi_fg_red(),
option_theme.ansi_fg_green(),
option_theme.ansi_fg_blue(),
option_theme.ansi_fg_yellow(),
];
let selected_index_for_action = index;
let button = Hoverable::new(mouse_state, move |_| {
let swatches = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::End)
.with_children(
swatch_colors
.iter()
.enumerate()
.map(|(i, color)| {
Container::new(
ConstrainedBox::new(
Container::new(Empty::new().finish())
.with_background_color(*color)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
SWATCH_RADIUS_PX,
)))
.finish(),
)
.with_width(SWATCH_DIAMETER_PX)
.with_height(SWATCH_DIAMETER_PX)
.finish(),
)
.with_margin_left(if i > 0 { 4. } else { 0. })
.finish()
})
.collect::<Vec<_>>(),
)
.finish();
ConstrainedBox::new(
Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
appearance
.ui_builder()
.paragraph(theme_name_owned.clone())
.with_style(UiComponentStyles {
font_size: Some(14.),
font_weight: Some(Weight::Medium),
font_color: Some(text_color.into()),
..Default::default()
})
.build()
.finish(),
)
.with_child(swatches)
.finish(),
)
.with_vertical_padding(16.)
.with_horizontal_padding(24.)
.with_background(background)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_border(Border::all(1.).with_border_fill(border_color))
.finish(),
)
.with_height(56.)
.finish()
});
let button = if interactive {
button
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ThemePickerSlideAction::SelectTheme {
index: selected_index_for_action,
});
})
.finish()
} else {
button.finish()
};
Container::new(button).with_margin_bottom(12.).finish()
}
/// All onboarding image paths used by the theme picker slide visual.
pub(crate) const VISUAL_IMAGE_PATHS: &'static [&'static str] = &[
// Terminal intention
"async/png/onboarding/terminal_intention/theme/theme_phenomenon_vertical.png",
"async/png/onboarding/terminal_intention/theme/theme_phenomenon_horizontal.png",
"async/png/onboarding/terminal_intention/theme/theme_dark_vertical.png",
"async/png/onboarding/terminal_intention/theme/theme_dark_horizontal.png",
"async/png/onboarding/terminal_intention/theme/theme_light_vertical.png",
"async/png/onboarding/terminal_intention/theme/theme_light_horizontal.png",
"async/png/onboarding/terminal_intention/theme/theme_adeberry_vertical.png",
"async/png/onboarding/terminal_intention/theme/theme_adeberry_horizontal.png",
// Agent intention
"async/png/onboarding/agent_intention/theme/theme_phenomenon_vertical.png",
"async/png/onboarding/agent_intention/theme/theme_phenomenon_horizontal.png",
"async/png/onboarding/agent_intention/theme/theme_dark_vertical.png",
"async/png/onboarding/agent_intention/theme/theme_dark_horizontal.png",
"async/png/onboarding/agent_intention/theme/theme_light_vertical.png",
"async/png/onboarding/agent_intention/theme/theme_light_horizontal.png",
"async/png/onboarding/agent_intention/theme/theme_adeberry_vertical.png",
"async/png/onboarding/agent_intention/theme/theme_adeberry_horizontal.png",
];
fn theme_visual_path(&self, app: &AppContext) -> &'static str {
let state = self.onboarding_state.as_ref(app);
let vertical = state.ui_customization().use_vertical_tabs;
let intention_dir = match state.intention() {
OnboardingIntention::AgentDrivenDevelopment => "agent_intention",
OnboardingIntention::Terminal => "terminal_intention",
};
let theme_name = self.theme_display_name(self.selected_theme_index);
let name_key = match theme_name.as_str() {
"Phenomenon" => "phenomenon",
"Dark" => "dark",
"Light" => "light",
"Adeberry" => "adeberry",
_ => "dark",
};
let orientation = if vertical { "vertical" } else { "horizontal" };
// Safety: all combinations are in VISUAL_IMAGE_PATHS.
Self::VISUAL_IMAGE_PATHS
.iter()
.find(|p| p.contains(intention_dir) && p.contains(name_key) && p.contains(orientation))
.unwrap_or(&Self::VISUAL_IMAGE_PATHS[0])
}
fn render_theme_picker_visual(
&self,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
if FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
let path = self.theme_visual_path(app);
layout::onboarding_right_panel_with_bg(path, layout::FOREGROUND_LAYOUT_DEFAULT)
} else {
theme_picker_visual(appearance)
}
}
}
impl Entity for ThemePickerSlide {
type Event = ThemePickerSlideEvent;
}
impl View for ThemePickerSlide {
fn ui_name() -> &'static str {
"ThemePickerSlide"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
// Background is rendered by the parent onboarding view (including background images).
layout::static_left(
|| self.render_theme_picker_content(appearance, app),
|| self.render_theme_picker_visual(appearance, app),
)
}
}
impl ThemePickerSlide {
fn render_sync_with_os_section(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let background_for_text = theme.background().into_solid();
let checkbox = appearance
.ui_builder()
.checkbox(self.sync_with_os_mouse.clone(), Some(12.))
.check(self.sync_with_os)
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(ThemePickerSlideAction::ToggleSyncWithOs)
})
.finish();
let label = Text::new(
"Sync light/dark theme with OS",
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();
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(checkbox)
.with_child(Container::new(label).with_margin_left(8.).finish())
.finish(),
)
.with_margin_top(24.)
.finish()
}
fn render_disclaimer_section(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let sub_text_color = internal_colors::text_sub(theme, theme.background().into_solid());
let ui_builder = appearance.ui_builder();
let disclaimer_styles = UiComponentStyles {
font_color: Some(sub_text_color),
font_size: Some(12.),
..Default::default()
};
let link_styles = UiComponentStyles {
font_size: Some(12.),
..Default::default()
};
// The disclaimer block is only rendered on the Terminal-without-Drive
// path (see `render_theme_picker_content`), where AI is not part of the
// selected onboarding settings; skip the "and AI features" wording.
let privacy_line = Flex::row()
.with_child(
ui_builder
.span("If you'd like to opt out of analytics, you can adjust your ")
.with_style(disclaimer_styles)
.build()
.finish(),
)
.with_child(
ui_builder
.link(
"Privacy Settings".into(),
None,
Some(Box::new(|ctx| {
ctx.dispatch_typed_action(
ThemePickerSlideAction::PrivacySettingsClicked,
);
})),
self.privacy_settings_mouse_state.clone(),
)
.soft_wrap(false)
.with_style(link_styles)
.build()
.finish(),
)
.finish();
let tos_line = Flex::row()
.with_child(
ui_builder
.span("By continuing, you agree to Warp's ")
.with_style(disclaimer_styles)
.build()
.finish(),
)
.with_child(
ui_builder
.link(
"Terms of Service".into(),
Some(TOS_URL.into()),
None,
self.tos_mouse_state.clone(),
)
.soft_wrap(false)
.with_style(link_styles)
.build()
.finish(),
)
.finish();
Container::new(
Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(privacy_line)
.with_child(Container::new(tos_line).with_margin_top(8.).finish())
.finish(),
)
.with_margin_top(24.)
.finish()
}
fn select_theme(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
self.sync_with_os = false;
self.selected_theme_index = index;
let theme_name = self.theme_display_name(index);
send_telemetry_from_ctx!(
OnboardingEvent::SettingChanged {
setting: "theme".to_string(),
value: theme_name.clone(),
},
ctx
);
ctx.emit(ThemePickerSlideEvent::ThemeSelected { theme_name });
ctx.notify();
}
fn next(&mut self, ctx: &mut ViewContext<Self>) {
self.onboarding_state.update(ctx, |model, ctx| {
if FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
model.complete(ctx);
} else {
model.next(ctx);
}
});
}
}
impl OnboardingSlide for ThemePickerSlide {
fn on_up(&mut self, ctx: &mut ViewContext<Self>) {
if self.sync_with_os {
return;
}
let selected_theme_index = self.selected_theme_index;
let theme_options_len = self.theme_options.len();
let up_index = if selected_theme_index == 0 {
theme_options_len.saturating_sub(1)
} else {
selected_theme_index - 1
};
self.select_theme(up_index, ctx);
}
fn on_down(&mut self, ctx: &mut ViewContext<Self>) {
if self.sync_with_os {
return;
}
let selected_theme_index = self.selected_theme_index;
let theme_options_len = self.theme_options.len();
let down_index = if selected_theme_index + 1 >= theme_options_len {
0
} else {
selected_theme_index + 1
};
self.select_theme(down_index, ctx);
}
fn on_enter(&mut self, ctx: &mut ViewContext<Self>) {
self.next(ctx);
}
}
impl TypedActionView for ThemePickerSlide {
type Action = ThemePickerSlideAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
ThemePickerSlideAction::SelectTheme { index } => {
if !self.sync_with_os {
self.select_theme(*index, ctx);
}
}
ThemePickerSlideAction::ToggleSyncWithOs => {
self.sync_with_os = !self.sync_with_os;
send_telemetry_from_ctx!(
OnboardingEvent::SettingChanged {
setting: "sync_with_os".to_string(),
value: self.sync_with_os.to_string(),
},
ctx
);
ctx.emit(ThemePickerSlideEvent::SyncWithOsToggled {
enabled: self.sync_with_os,
});
ctx.notify();
}
ThemePickerSlideAction::BackClicked => {
let onboarding_state = self.onboarding_state.clone();
onboarding_state.update(ctx, |model, ctx| {
model.back(ctx);
});
}
ThemePickerSlideAction::NextClicked => {
self.next(ctx);
}
ThemePickerSlideAction::PrivacySettingsClicked => {
ctx.emit(ThemePickerSlideEvent::PrivacySettingsRequested);
}
}
}
}
@@ -0,0 +1,486 @@
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 ui_components::{button, Component as _, Options as _};
use warp_core::ui::appearance::Appearance;
use warp_core::ui::theme::color::internal_colors;
use warpui::prelude::Align;
use warpui::{
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,
};
/// Which setting card is currently expanded.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SettingCard {
CliToolbar,
Notifications,
}
#[derive(Debug, Clone)]
pub enum ThirdPartySlideAction {
SelectSettingCard { card: SettingCard },
SetCliAgentToolbarEnabled { enabled: bool },
SetShowAgentNotifications { enabled: bool },
BackClicked,
NextClicked,
}
pub struct ThirdPartySlide {
onboarding_state: ModelHandle<OnboardingStateModel>,
selected_setting: Option<SettingCard>,
cli_toolbar_card_mouse_state: MouseStateHandle,
notifications_card_mouse_state: MouseStateHandle,
cli_toolbar_seg_left_mouse: MouseStateHandle,
cli_toolbar_seg_right_mouse: MouseStateHandle,
notifications_seg_left_mouse: MouseStateHandle,
notifications_seg_right_mouse: MouseStateHandle,
back_button: button::Button,
next_button: button::Button,
scroll_state: ClippedScrollStateHandle,
}
impl ThirdPartySlide {
pub(crate) fn new(
onboarding_state: ModelHandle<OnboardingStateModel>,
ctx: &mut ViewContext<Self>,
) -> Self {
ctx.subscribe_to_model(&onboarding_state, |_me, _model, event, ctx| {
if matches!(event, OnboardingStateEvent::IntentionChanged) {
ctx.notify();
}
});
Self {
onboarding_state,
selected_setting: None,
cli_toolbar_card_mouse_state: MouseStateHandle::default(),
notifications_card_mouse_state: MouseStateHandle::default(),
cli_toolbar_seg_left_mouse: MouseStateHandle::default(),
cli_toolbar_seg_right_mouse: MouseStateHandle::default(),
notifications_seg_left_mouse: MouseStateHandle::default(),
notifications_seg_right_mouse: MouseStateHandle::default(),
back_button: button::Button::default(),
next_button: button::Button::default(),
scroll_state: ClippedScrollStateHandle::new(),
}
}
/// All onboarding image paths used by this slide's visual.
pub(crate) const VISUAL_IMAGE_PATHS: &'static [&'static str] = &[
"async/png/onboarding/thirdparty_toolbar_enabled_vertical.png",
"async/png/onboarding/thirdparty_toolbar_enabled_horizontal.png",
"async/png/onboarding/thirdparty_toolbar_disabled_vertical.png",
"async/png/onboarding/thirdparty_toolbar_disabled_horizontal.png",
"async/png/onboarding/thirdparty_notifications_enabled.png",
"async/png/onboarding/thirdparty_notifications_disabled.png",
];
fn cli_agent_toolbar_enabled(&self, app: &AppContext) -> bool {
self.onboarding_state
.as_ref(app)
.agent_settings()
.cli_agent_toolbar_enabled
}
fn show_agent_notifications(&self, app: &AppContext) -> bool {
self.onboarding_state
.as_ref(app)
.agent_settings()
.show_agent_notifications
}
fn model_intention(&self, app: &AppContext) -> OnboardingIntention {
*self.onboarding_state.as_ref(app).intention()
}
fn render_content(
&self,
appearance: &Appearance,
cli_toolbar_enabled: bool,
show_agent_notifications: bool,
intention: OnboardingIntention,
) -> Box<dyn Element> {
let bottom_nav = Align::new(self.render_bottom_nav(appearance, intention)).finish();
let mut sections = vec![
self.render_header(appearance),
self.render_toolbar_section(appearance, cli_toolbar_enabled),
];
// Only show the notifications toggle for terminal intention.
// For agent intention, notifications are always enabled.
if matches!(intention, OnboardingIntention::Terminal) {
sections.push(self.render_notifications_section(appearance, show_agent_notifications));
}
slide_content::onboarding_slide_content(
sections,
bottom_nav,
self.scroll_state.clone(),
appearance,
)
}
fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> {
let title = appearance
.ui_builder()
.paragraph("Customize third party agents")
.with_style(UiComponentStyles {
font_size: Some(36.),
font_weight: Some(Weight::Medium),
..Default::default()
})
.build()
.finish();
let subtitle = FormattedTextElement::from_str(
"Select defaults for using agents like Claude Code, Codex, and Gemini.",
appearance.ui_font_family(),
16.,
)
.with_color(internal_colors::text_sub(
appearance.theme(),
appearance.theme().background().into_solid(),
))
.with_weight(Weight::Normal)
.with_alignment(TextAlignment::Left)
.with_line_height_ratio(1.0)
.finish();
Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(title)
.with_child(Container::new(subtitle).with_margin_top(16.).finish())
.finish()
}
fn render_toolbar_section(
&self,
appearance: &Appearance,
cli_toolbar_enabled: bool,
) -> Box<dyn Element> {
let is_selected = self.selected_setting == Some(SettingCard::CliToolbar);
let card = render_toggle_card(
appearance,
ToggleCardSpec {
title: "CLI agent toolbar",
is_expanded: is_selected,
is_left_selected: cli_toolbar_enabled,
left_label: "Enabled",
right_label: "Disabled",
card_mouse_state: self.cli_toolbar_card_mouse_state.clone(),
on_expand: Box::new(|ctx, _, _| {
ctx.dispatch_typed_action(ThirdPartySlideAction::SelectSettingCard {
card: SettingCard::CliToolbar,
});
}),
left_mouse: self.cli_toolbar_seg_left_mouse.clone(),
right_mouse: self.cli_toolbar_seg_right_mouse.clone(),
on_left: Box::new(|ctx, _, _| {
ctx.dispatch_typed_action(ThirdPartySlideAction::SetCliAgentToolbarEnabled {
enabled: true,
});
}),
on_right: Box::new(|ctx, _, _| {
ctx.dispatch_typed_action(ThirdPartySlideAction::SetCliAgentToolbarEnabled {
enabled: false,
});
}),
chips: vec![],
},
);
Container::new(
Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(card)
.finish(),
)
.with_margin_top(40.)
.finish()
}
fn render_notifications_section(
&self,
appearance: &Appearance,
show_agent_notifications: bool,
) -> Box<dyn Element> {
let is_selected = self.selected_setting == Some(SettingCard::Notifications);
let card = render_toggle_card(
appearance,
ToggleCardSpec {
title: "Notifications",
is_expanded: is_selected,
is_left_selected: show_agent_notifications,
left_label: "Enabled",
right_label: "Disabled",
card_mouse_state: self.notifications_card_mouse_state.clone(),
on_expand: Box::new(|ctx, _, _| {
ctx.dispatch_typed_action(ThirdPartySlideAction::SelectSettingCard {
card: SettingCard::Notifications,
});
}),
left_mouse: self.notifications_seg_left_mouse.clone(),
right_mouse: self.notifications_seg_right_mouse.clone(),
on_left: Box::new(|ctx, _, _| {
ctx.dispatch_typed_action(ThirdPartySlideAction::SetShowAgentNotifications {
enabled: true,
});
}),
on_right: Box::new(|ctx, _, _| {
ctx.dispatch_typed_action(ThirdPartySlideAction::SetShowAgentNotifications {
enabled: false,
});
}),
chips: vec![],
},
);
Container::new(
Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(card)
.finish(),
)
.with_margin_top(16.)
.finish()
}
fn render_bottom_nav(
&self,
appearance: &Appearance,
intention: OnboardingIntention,
) -> 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(ThirdPartySlideAction::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(ThirdPartySlideAction::NextClicked);
})),
..button::Options::default(appearance)
},
},
);
let is_terminal = matches!(intention, OnboardingIntention::Terminal);
let (step_index, step_count) = if is_terminal { (2, 4) } else { (3, 5) };
bottom_nav::onboarding_bottom_nav(
appearance,
step_index,
step_count,
Some(back_button),
Some(next_button),
)
}
fn render_visual(
&self,
cli_toolbar_enabled: bool,
show_agent_notifications: bool,
vertical: bool,
) -> Box<dyn Element> {
if self.selected_setting == Some(SettingCard::Notifications) {
let path = if show_agent_notifications {
Self::VISUAL_IMAGE_PATHS[4]
} else {
Self::VISUAL_IMAGE_PATHS[5]
};
layout::onboarding_right_panel_with_bg(path, layout::FOREGROUND_LAYOUT_CODE_REVIEW)
} else {
let path = match (cli_toolbar_enabled, vertical) {
(true, true) => Self::VISUAL_IMAGE_PATHS[0],
(true, false) => Self::VISUAL_IMAGE_PATHS[1],
(false, true) => Self::VISUAL_IMAGE_PATHS[2],
(false, false) => Self::VISUAL_IMAGE_PATHS[3],
};
layout::onboarding_right_panel_with_bg(path, layout::FOREGROUND_LAYOUT_THIRD_PARTY)
}
}
}
impl Entity for ThirdPartySlide {
type Event = ();
}
impl View for ThirdPartySlide {
fn ui_name() -> &'static str {
"ThirdPartySlide"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let cli_toolbar_enabled = self.cli_agent_toolbar_enabled(app);
let show_agent_notifications = self.show_agent_notifications(app);
let intention = self.model_intention(app);
let vertical = self
.onboarding_state
.as_ref(app)
.ui_customization()
.use_vertical_tabs;
layout::static_left(
|| {
self.render_content(
appearance,
cli_toolbar_enabled,
show_agent_notifications,
intention,
)
},
|| self.render_visual(cli_toolbar_enabled, show_agent_notifications, vertical),
)
}
}
impl ThirdPartySlide {
fn select_setting_card(&mut self, card: SettingCard, ctx: &mut ViewContext<Self>) {
self.selected_setting = Some(card);
ctx.notify();
}
fn next(&mut self, ctx: &mut ViewContext<Self>) {
self.onboarding_state.update(ctx, |model, ctx| {
model.next(ctx);
});
}
}
impl OnboardingSlide for ThirdPartySlide {
fn on_up(&mut self, ctx: &mut ViewContext<Self>) {
let new_card = match self.selected_setting {
None => SettingCard::CliToolbar,
Some(SettingCard::CliToolbar) => SettingCard::CliToolbar,
Some(SettingCard::Notifications) => SettingCard::CliToolbar,
};
self.selected_setting = Some(new_card);
ctx.notify();
}
fn on_down(&mut self, ctx: &mut ViewContext<Self>) {
let is_terminal = matches!(self.model_intention(ctx), OnboardingIntention::Terminal);
let new_card = match self.selected_setting {
None => SettingCard::CliToolbar,
Some(SettingCard::CliToolbar) => {
if is_terminal {
SettingCard::Notifications
} else {
SettingCard::CliToolbar
}
}
Some(SettingCard::Notifications) => SettingCard::Notifications,
};
self.selected_setting = Some(new_card);
ctx.notify();
}
fn on_left(&mut self, ctx: &mut ViewContext<Self>) {
match self.selected_setting {
Some(SettingCard::CliToolbar) => {
self.onboarding_state.update(ctx, |model, ctx| {
model.set_cli_agent_toolbar_enabled(true, ctx);
});
ctx.notify();
}
Some(SettingCard::Notifications) => {
self.onboarding_state.update(ctx, |model, ctx| {
model.set_show_agent_notifications(true, ctx);
});
ctx.notify();
}
None => {}
}
}
fn on_right(&mut self, ctx: &mut ViewContext<Self>) {
match self.selected_setting {
Some(SettingCard::CliToolbar) => {
self.onboarding_state.update(ctx, |model, ctx| {
model.set_cli_agent_toolbar_enabled(false, ctx);
});
ctx.notify();
}
Some(SettingCard::Notifications) => {
self.onboarding_state.update(ctx, |model, ctx| {
model.set_show_agent_notifications(false, ctx);
});
ctx.notify();
}
None => {}
}
}
fn on_enter(&mut self, ctx: &mut ViewContext<Self>) {
self.next(ctx);
}
}
impl TypedActionView for ThirdPartySlide {
type Action = ThirdPartySlideAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
ThirdPartySlideAction::SelectSettingCard { card } => {
self.select_setting_card(*card, ctx);
}
ThirdPartySlideAction::SetCliAgentToolbarEnabled { enabled } => {
let value = *enabled;
self.onboarding_state.update(ctx, |model, ctx| {
model.set_cli_agent_toolbar_enabled(value, ctx);
});
ctx.notify();
}
ThirdPartySlideAction::SetShowAgentNotifications { enabled } => {
let value = *enabled;
self.onboarding_state.update(ctx, |model, ctx| {
model.set_show_agent_notifications(value, ctx);
});
ctx.notify();
}
ThirdPartySlideAction::BackClicked => {
let onboarding_state = self.onboarding_state.clone();
onboarding_state.update(ctx, |model, ctx| {
model.back(ctx);
});
}
ThirdPartySlideAction::NextClicked => {
self.next(ctx);
}
}
}
}
+308
View File
@@ -0,0 +1,308 @@
use pathfinder_geometry::vector::Vector2F;
use warp_core::ui::theme::Fill;
use warp_core::ui::{appearance::Appearance, theme::color::internal_colors};
use warpui::prelude::Align;
use warpui::{
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,
};
pub(super) type ClickCallback = Box<dyn FnMut(&mut EventContext, &AppContext, Vector2F) + 'static>;
pub(super) type HoverCallback =
Box<dyn FnMut(bool, &mut EventContext, &AppContext, Vector2F) + 'static>;
pub(super) struct ChipSpec {
pub label: &'static str,
pub is_enabled: bool,
pub mouse_state: MouseStateHandle,
pub on_click: ClickCallback,
pub on_hover: Option<HoverCallback>,
}
pub(super) struct ToggleCardSpec {
pub title: &'static str,
pub is_expanded: bool,
pub is_left_selected: bool,
pub left_label: &'static str,
pub right_label: &'static str,
pub card_mouse_state: MouseStateHandle,
pub on_expand: ClickCallback,
pub left_mouse: MouseStateHandle,
pub right_mouse: MouseStateHandle,
pub on_left: ClickCallback,
pub on_right: ClickCallback,
pub chips: Vec<ChipSpec>,
}
pub(super) fn render_toggle_card(
appearance: &Appearance,
spec: ToggleCardSpec,
) -> Box<dyn Element> {
if spec.is_expanded {
render_expanded(appearance, spec)
} else {
render_collapsed(appearance, spec)
}
}
fn collapsed_subtitle(
is_enabled: bool,
left_label: &str,
right_label: &str,
chips: &[ChipSpec],
) -> String {
if !is_enabled {
return right_label.to_string();
}
if chips.is_empty() {
return left_label.to_string();
}
let enabled_labels: Vec<&str> = chips
.iter()
.filter(|c| c.is_enabled)
.map(|c| c.label)
.collect();
if enabled_labels.is_empty() {
return left_label.to_string();
}
let joined = enabled_labels.join(", ");
let mut chars = joined.chars();
match chars.next() {
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
None => String::new(),
}
}
fn render_collapsed(appearance: &Appearance, spec: ToggleCardSpec) -> Box<dyn Element> {
let theme = appearance.theme();
let ui_font_family = appearance.ui_font_family();
let text_color = internal_colors::text_sub(theme, theme.background().into_solid());
let border_color = Fill::Solid(internal_colors::neutral_4(theme));
let subtitle = collapsed_subtitle(
spec.is_left_selected,
spec.left_label,
spec.right_label,
&spec.chips,
);
let mut on_expand = spec.on_expand;
Hoverable::new(spec.card_mouse_state, move |_| {
let title_el = FormattedTextElement::from_str(spec.title, ui_font_family, 16.)
.with_color(text_color)
.with_weight(Weight::Normal)
.with_alignment(TextAlignment::Left)
.with_line_height_ratio(1.0)
.finish();
let sub_el = Text::new(subtitle.clone(), ui_font_family, 12.)
.with_color(text_color)
.with_line_height_ratio(1.0)
.finish();
let content = Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(title_el)
.with_child(Container::new(sub_el).with_margin_top(12.).finish())
.finish();
Container::new(content)
.with_uniform_padding(24.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_border(Border::all(1.).with_border_fill(border_color))
.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, app, pos| {
on_expand(ctx, app, pos);
})
.finish()
}
fn render_expanded(appearance: &Appearance, spec: ToggleCardSpec) -> Box<dyn Element> {
let theme = appearance.theme();
let ui_font_family = appearance.ui_font_family();
let text_color = internal_colors::text_main(theme, theme.background().into_solid());
let border_color = theme.accent();
let background = internal_colors::accent_overlay_1(theme);
let title_el = FormattedTextElement::from_str(spec.title, ui_font_family, 16.)
.with_color(text_color)
.with_weight(Weight::Normal)
.with_alignment(TextAlignment::Left)
.with_line_height_ratio(1.0)
.finish();
let seg_control = render_inline_segmented_control(
appearance,
spec.is_left_selected,
spec.left_label,
spec.right_label,
spec.left_mouse,
spec.right_mouse,
spec.on_left,
spec.on_right,
);
let mut content = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(title_el)
.with_child(Container::new(seg_control).with_margin_top(12.).finish());
if !spec.chips.is_empty() {
let chips_el = render_chips(appearance, spec.chips);
content = content.with_child(Container::new(chips_el).with_margin_top(12.).finish());
}
Container::new(content.finish())
.with_uniform_padding(24.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_border(Border::all(1.).with_border_fill(border_color))
.with_background(background)
.finish()
}
#[allow(clippy::too_many_arguments)]
pub(super) fn render_inline_segmented_control(
appearance: &Appearance,
is_left_selected: bool,
left_label: &'static str,
right_label: &'static str,
enabled_mouse: MouseStateHandle,
disabled_mouse: MouseStateHandle,
on_left: ClickCallback,
on_right: ClickCallback,
) -> Box<dyn Element> {
let theme = appearance.theme();
let ui_font_family = appearance.ui_font_family();
let selected_bg = internal_colors::accent_overlay_3(theme);
let text_main = internal_colors::text_main(theme, theme.background().into_solid());
let text_sub = internal_colors::text_sub(theme, theme.background().into_solid());
let control_bg = internal_colors::fg_overlay_1(theme);
let build_option = move |label: &'static str,
is_selected: bool,
mouse: MouseStateHandle,
mut callback: ClickCallback| {
let option = Hoverable::new(mouse, move |_| {
let label_el = FormattedTextElement::from_str(label, ui_font_family, 14.)
.with_color(if is_selected { text_main } else { text_sub })
.with_weight(Weight::Normal)
.with_alignment(TextAlignment::Center)
.with_line_height_ratio(1.0)
.finish();
let aligned = Align::new(label_el).finish();
let mut container = Container::new(aligned)
.with_padding_left(8.)
.with_padding_right(8.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)));
if is_selected {
container = container.with_background(selected_bg);
}
container.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, app, pos| {
callback(ctx, app, pos);
})
.finish();
Shrinkable::new(1., option).finish()
};
let left = build_option(left_label, is_left_selected, enabled_mouse, on_left);
let right = build_option(right_label, !is_left_selected, disabled_mouse, on_right);
Container::new(
ConstrainedBox::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(left)
.with_child(right)
.finish(),
)
.with_height(24.)
.finish(),
)
.with_uniform_padding(4.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
.with_background(control_bg)
.finish()
}
fn render_chips(appearance: &Appearance, chips: Vec<ChipSpec>) -> Box<dyn Element> {
let mut wrap = Wrap::row().with_spacing(12.).with_run_spacing(12.);
wrap.extend(chips.into_iter().map(|chip| render_chip(appearance, chip)));
wrap.finish()
}
fn render_chip(appearance: &Appearance, mut chip: ChipSpec) -> Box<dyn Element> {
let theme = appearance.theme();
let ui_font_family = appearance.ui_font_family();
let (bg, border) = if chip.is_enabled {
(
Some(internal_colors::accent_overlay_2(theme)),
Some(theme.accent()),
)
} else {
(Some(internal_colors::fg_overlay_1(theme)), None)
};
let text_color = if chip.is_enabled {
internal_colors::text_main(theme, theme.background().into_solid())
} else {
internal_colors::text_sub(theme, theme.background().into_solid())
};
let label = chip.label;
let mut hoverable = Hoverable::new(chip.mouse_state, move |_| {
let label_el = FormattedTextElement::from_str(label, ui_font_family, 14.)
.with_color(text_color)
.with_weight(Weight::Normal)
.with_alignment(TextAlignment::Center)
.with_line_height_ratio(1.0)
.finish();
let mut container = Container::new(Align::new(label_el).finish())
.with_padding_left(12.)
.with_padding_right(12.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)));
if let Some(bg) = bg {
container = container.with_background(bg);
}
let border_fill = border.unwrap_or(Fill::Solid(pathfinder_color::ColorU::new(0, 0, 0, 0)));
container = container.with_border(Border::all(1.).with_border_fill(border_fill));
ConstrainedBox::new(container.finish())
.with_height(32.)
.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, app, pos| {
(chip.on_click)(ctx, app, pos);
});
if let Some(mut hover_cb) = chip.on_hover {
hoverable = hoverable.on_hover(move |is_hovered, ctx, app, pos| {
hover_cb(is_hovered, ctx, app, pos);
});
}
hoverable.finish()
}
@@ -0,0 +1,192 @@
use super::agent_slide::AgentSlideAction;
use pathfinder_geometry::vector::vec2f;
use warp_core::ui::{
appearance::Appearance,
icons::Icon,
theme::{color::internal_colors, Fill},
};
use warpui::{
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,
};
pub(super) struct TwoLineButtonSpec {
pub(super) is_selected: bool,
pub(super) title: String,
pub(super) subtitle: String,
pub(super) height: f32,
pub(super) mouse_state: MouseStateHandle,
pub(super) click_action: AgentSlideAction,
pub(super) subtitle_font_size: f32,
pub(super) title_color: Fill,
pub(super) subtitle_color: Fill,
/// Optional icon to display before the title.
pub(super) icon: Option<Icon>,
/// If set, the button is disabled and this text is shown as a badge.
pub(super) disabled_badge: Option<String>,
/// When true, the button is fully disabled: muted colors, no selected
/// state, no click cursor, and no click handler.
pub(super) is_disabled: bool,
}
pub(super) fn render_two_line_button(
appearance: &Appearance,
spec: TwoLineButtonSpec,
) -> Box<dyn Element> {
const RADIUS: f32 = 8.;
let TwoLineButtonSpec {
is_selected,
title,
subtitle,
height,
mouse_state,
click_action,
subtitle_font_size,
title_color,
subtitle_color,
icon,
disabled_badge,
is_disabled,
} = spec;
let theme = appearance.theme();
let is_disabled = is_disabled || disabled_badge.is_some();
// Disabled models always use muted colors and never show as selected.
let effective_selected = is_selected && !is_disabled;
let (title_fill, subtitle_fill, background) = match (effective_selected, is_disabled) {
(true, _) => {
let bg_color = internal_colors::accent_overlay_1(theme);
let selected_color = internal_colors::accent_fg_strong(theme);
(selected_color, selected_color, Some(bg_color))
}
(false, true) => {
let bg_color = theme.surface_2();
let disabled_color = theme.disabled_text_color(bg_color);
(disabled_color, disabled_color, Some(bg_color))
}
(false, false) => (title_color, subtitle_color, None),
};
let border_color = if effective_selected {
theme.accent()
} else {
Fill::Solid(internal_colors::neutral_4(theme))
};
let ui_font_family = appearance.ui_font_family();
let hoverable = Hoverable::new(mouse_state, move |_| {
let title_text = Text::new(title.clone(), ui_font_family, 14.0)
.with_color(title_fill.into_solid())
.with_style(Properties {
weight: Weight::Normal,
..Default::default()
})
.with_line_height_ratio(1.0)
.finish();
// Build title row with optional icon
let title_el: Box<dyn Element> = if let Some(icon) = icon {
const ICON_SIZE: f32 = 14.;
let icon_el = ConstrainedBox::new(Box::new(icon.to_warpui_icon(title_fill)))
.with_width(ICON_SIZE)
.with_height(ICON_SIZE)
.finish();
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(icon_el)
.with_child(Container::new(title_text).with_margin_left(4.).finish())
.finish()
} else {
title_text
};
let subtitle_el = Text::new(subtitle.clone(), ui_font_family, subtitle_font_size)
.with_color(subtitle_fill.into_solid())
.with_style(Properties {
weight: Weight::Normal,
..Default::default()
})
.with_line_height_ratio(1.0)
.finish();
let content = Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(title_el)
.with_child(Container::new(subtitle_el).with_margin_top(8.).finish())
.finish();
let aligned = Align::new(content).left().finish();
let mut container = Container::new(aligned)
.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);
}
let button_el = ConstrainedBox::new(container.finish())
.with_min_height(height)
.finish();
// Overlay the badge pill at bottom-right if disabled
if let Some(ref badge_text) = disabled_badge {
let badge_label = Text::new(badge_text.clone(), ui_font_family, 11.0)
.with_color(internal_colors::neutral_1(theme))
.with_style(Properties {
weight: Weight::Medium,
..Default::default()
})
.with_line_height_ratio(1.0)
.finish();
let badge = Container::new(badge_label)
.with_padding_left(6.)
.with_padding_right(6.)
.with_padding_top(2.)
.with_padding_bottom(2.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_background_color(theme.ansi_fg_green())
.finish();
let mut stack = Stack::new();
stack.add_child(button_el);
stack.add_positioned_child(
badge,
OffsetPositioning::offset_from_parent(
vec2f(-6., -6.),
ParentOffsetBounds::ParentByPosition,
ParentAnchor::BottomRight,
ChildAnchor::BottomRight,
),
);
stack.finish()
} else {
button_el
}
});
if is_disabled {
// Disabled: no click handler, default cursor
hoverable.finish()
} else {
hoverable
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(click_action.clone());
})
.finish()
}
}
+217
View File
@@ -0,0 +1,217 @@
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use strum_macros::{EnumDiscriminants, EnumIter};
use warp_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
/// Telemetry events for the onboarding flow.
#[derive(Clone, Debug, Serialize, Deserialize, EnumDiscriminants)]
#[strum_discriminants(derive(EnumIter))]
#[strum_discriminants(name(OnboardingEventDiscriminant))]
pub enum OnboardingEvent {
/// The onboarding flow was started.
OnboardingStarted,
/// A specific slide was viewed.
SlideViewed { slide_name: String },
/// A setting was changed during onboarding.
SettingChanged { setting: String, value: String },
/// The onboarding slides were completed.
OnboardingSlidesCompleted {
intention: String,
model: Option<String>,
autonomy: Option<String>,
has_project_path: bool,
},
/// The user clicked the "Get Started" button.
GetStartedClicked,
/// The user started folder selection.
FolderSelectionStarted,
/// The user selected a folder.
FolderSelected,
/// A callout was displayed.
CalloutDisplayed { callout: String },
/// The user clicked next on a callout.
CalloutNext,
/// The user completed the callout flow.
CalloutCompleted { completion_type: String },
/// The user navigated to the next slide.
SlideNavigatedNext,
/// The user navigated to the previous slide.
SlideNavigatedBack,
/// The user clicked the upgrade/subscribe button on the FreeUserNoAi experiment slide.
FreeUserNoAiUpgradeClicked,
/// 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.
WelcomeLoginClicked,
}
impl TelemetryEvent for OnboardingEvent {
fn name(&self) -> &'static str {
match self {
OnboardingEvent::OnboardingStarted => "onboarding_started",
OnboardingEvent::SlideViewed { .. } => "onboarding_slide_viewed",
OnboardingEvent::SettingChanged { .. } => "onboarding_setting_changed",
OnboardingEvent::OnboardingSlidesCompleted { .. } => "onboarding_slides_completed",
OnboardingEvent::GetStartedClicked => "onboarding_get_started_clicked",
OnboardingEvent::FolderSelectionStarted => "onboarding_folder_selection_started",
OnboardingEvent::FolderSelected => "onboarding_folder_selected",
OnboardingEvent::CalloutDisplayed { .. } => "onboarding_callout_displayed",
OnboardingEvent::CalloutNext => "onboarding_callout_next",
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::AgentSlideUpgradeClicked => "onboarding_agent_slide_upgrade_clicked",
OnboardingEvent::WelcomeLoginClicked => "onboarding_welcome_login_clicked",
}
}
fn payload(&self) -> Option<Value> {
match self {
OnboardingEvent::OnboardingStarted => None,
OnboardingEvent::SlideViewed { slide_name } => Some(json!({
"slide_name": slide_name,
})),
OnboardingEvent::SettingChanged { setting, value } => Some(json!({
"setting": setting,
"value": value,
})),
OnboardingEvent::OnboardingSlidesCompleted {
intention,
model,
autonomy,
has_project_path,
} => Some(json!({
"intention": intention,
"model": model,
"autonomy": autonomy,
"has_project_path": has_project_path,
})),
OnboardingEvent::GetStartedClicked => None,
OnboardingEvent::FolderSelectionStarted => None,
OnboardingEvent::FolderSelected => None,
OnboardingEvent::CalloutDisplayed { callout } => Some(json!({
"callout": callout,
})),
OnboardingEvent::CalloutNext => None,
OnboardingEvent::CalloutCompleted { completion_type } => Some(json!({
"completion_type": completion_type,
})),
OnboardingEvent::SlideNavigatedNext => None,
OnboardingEvent::SlideNavigatedBack => None,
OnboardingEvent::FreeUserNoAiUpgradeClicked => None,
OnboardingEvent::AgentSlideUpgradeClicked => None,
OnboardingEvent::WelcomeLoginClicked => None,
}
}
fn description(&self) -> &'static str {
match self {
OnboardingEvent::OnboardingStarted => "User started the onboarding flow",
OnboardingEvent::SlideViewed { .. } => "User viewed a slide in the onboarding flow",
OnboardingEvent::SettingChanged { .. } => "User changed a setting during onboarding",
OnboardingEvent::OnboardingSlidesCompleted { .. } => {
"User completed the onboarding slides"
}
OnboardingEvent::GetStartedClicked => "User clicked the Get Started button",
OnboardingEvent::FolderSelectionStarted => "User started folder selection",
OnboardingEvent::FolderSelected => "User selected a folder",
OnboardingEvent::CalloutDisplayed { .. } => "A callout was displayed to the user",
OnboardingEvent::CalloutNext => "User clicked next on a callout",
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::AgentSlideUpgradeClicked => {
"User clicked the Upgrade button on the Customize your agent slide"
}
OnboardingEvent::WelcomeLoginClicked => {
"User clicked the Log in link on the welcome/intro slide"
}
}
}
fn enablement_state(&self) -> EnablementState {
EnablementState::Always
}
fn contains_ugc(&self) -> bool {
false
}
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
warp_core::telemetry::enum_events::<Self>()
}
}
impl TelemetryEventDesc for OnboardingEventDiscriminant {
fn name(&self) -> &'static str {
match self {
OnboardingEventDiscriminant::OnboardingStarted => "onboarding_started",
OnboardingEventDiscriminant::SlideViewed => "onboarding_slide_viewed",
OnboardingEventDiscriminant::SettingChanged => "onboarding_setting_changed",
OnboardingEventDiscriminant::OnboardingSlidesCompleted => "onboarding_slides_completed",
OnboardingEventDiscriminant::GetStartedClicked => "onboarding_get_started_clicked",
OnboardingEventDiscriminant::FolderSelectionStarted => {
"onboarding_folder_selection_started"
}
OnboardingEventDiscriminant::FolderSelected => "onboarding_folder_selected",
OnboardingEventDiscriminant::CalloutDisplayed => "onboarding_callout_displayed",
OnboardingEventDiscriminant::CalloutNext => "onboarding_callout_next",
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::AgentSlideUpgradeClicked => {
"onboarding_agent_slide_upgrade_clicked"
}
OnboardingEventDiscriminant::WelcomeLoginClicked => "onboarding_welcome_login_clicked",
}
}
fn description(&self) -> &'static str {
match self {
OnboardingEventDiscriminant::OnboardingStarted => "User started the onboarding flow",
OnboardingEventDiscriminant::SlideViewed => {
"User viewed a slide in the onboarding flow"
}
OnboardingEventDiscriminant::SettingChanged => {
"User changed a setting during onboarding"
}
OnboardingEventDiscriminant::OnboardingSlidesCompleted => {
"User completed the onboarding slides"
}
OnboardingEventDiscriminant::GetStartedClicked => "User clicked the Get Started button",
OnboardingEventDiscriminant::FolderSelectionStarted => "User started folder selection",
OnboardingEventDiscriminant::FolderSelected => "User selected a folder",
OnboardingEventDiscriminant::CalloutDisplayed => "A callout was displayed to the user",
OnboardingEventDiscriminant::CalloutNext => "User clicked next on a callout",
OnboardingEventDiscriminant::CalloutCompleted => "User completed the callout flow",
OnboardingEventDiscriminant::SlideNavigatedNext => "User navigated to the next slide",
OnboardingEventDiscriminant::SlideNavigatedBack => {
"User navigated to the previous slide"
}
OnboardingEventDiscriminant::FreeUserNoAiUpgradeClicked => {
"User clicked the upgrade button on the free-user no-AI experiment slide"
}
OnboardingEventDiscriminant::AgentSlideUpgradeClicked => {
"User clicked the Upgrade button on the Customize your agent slide"
}
OnboardingEventDiscriminant::WelcomeLoginClicked => {
"User clicked the Log in link on the welcome/intro slide"
}
}
}
fn enablement_state(&self) -> EnablementState {
EnablementState::Always
}
}
warp_core::register_telemetry_event!(OnboardingEvent);
@@ -0,0 +1,31 @@
use warp_core::telemetry::{TelemetryContextModel, TelemetryContextProvider};
use warpui::{AppContext, ModelContext};
/// A mock telemetry context provider for the onboarding binary that logs events
/// instead of sending them to a server.
///
/// Since the onboarding binary runs standalone without authentication,
/// we use a fixed anonymous ID and log all telemetry events.
pub struct MockTelemetryContextProvider {
anonymous_id: String,
}
impl MockTelemetryContextProvider {
pub fn new_context_provider(
_ctx: &mut ModelContext<TelemetryContextModel>,
) -> TelemetryContextModel {
let anonymous_id = uuid::Uuid::new_v4().to_string();
Box::new(Self { anonymous_id })
}
}
impl TelemetryContextProvider for MockTelemetryContextProvider {
fn user_id(&self, _ctx: &AppContext) -> Option<String> {
// No user ID for the standalone onboarding binary
None
}
fn anonymous_id(&self, _ctx: &AppContext) -> String {
self.anonymous_id.clone()
}
}
@@ -0,0 +1,88 @@
use pathfinder_color::ColorU;
use warpui::elements::Align;
use warpui::Element;
use super::onboarding_visual::{OnboardingVisual, Pill, RectPct};
pub(crate) fn agent_visual(
panel_background: ColorU,
neutral: ColorU,
blue: ColorU,
green: ColorU,
yellow: ColorU,
) -> Box<dyn Element> {
// X is in percent of the inner panel width; negative values intentionally protrude.
const LEFT_PROTRUSION_PCT: f32 = -0.06;
const RIGHT_PROTRUSION_PCT: f32 = 0.03;
const BAR_H_PCT: f32 = 0.040;
let mut pills = Vec::new();
// Top-left neutral bars.
let top_left = [(0.38, 0.18), (0.55, 0.24), (0.42, 0.30), (0.48, 0.36)];
pills.extend(top_left.into_iter().map(|(w_pct, y_pct)| Pill {
rect: RectPct::new(LEFT_PROTRUSION_PCT, y_pct, w_pct, BAR_H_PCT),
color: neutral,
}));
// Upper-right blue bars.
let blue_bars = [(0.26, 0.12), (0.16, 0.18)];
pills.extend(blue_bars.into_iter().map(|(w_pct, y_pct)| Pill {
rect: RectPct::new(1.0 - w_pct + RIGHT_PROTRUSION_PCT, y_pct, w_pct, BAR_H_PCT),
color: blue,
}));
// Mid section: 3 full-width (with overhang) bars, 2x height, evenly spaced.
let full_w = 1.0 + (-LEFT_PROTRUSION_PCT) + RIGHT_PROTRUSION_PCT;
let full_x = LEFT_PROTRUSION_PCT;
let tall_h = BAR_H_PCT * 2.0;
let gap_h = 0.02;
let mut y = 0.5;
for _ in 0..3 {
pills.push(Pill {
rect: RectPct::new(full_x, y, full_w, tall_h),
color: neutral,
});
y += tall_h + gap_h;
}
// Bottom container + rows.
const BOTTOM_Y_PCT: f32 = 0.80;
const ROW_GAP_PCT: f32 = 0.012;
let bottom_w = 1.0 + (-LEFT_PROTRUSION_PCT) + RIGHT_PROTRUSION_PCT;
let row_x = LEFT_PROTRUSION_PCT + 0.02;
let row_w = bottom_w - 0.04;
let mut row_y = BOTTOM_Y_PCT + 0.03;
// Row 1: full green.
pills.push(Pill {
rect: RectPct::new(row_x, row_y, row_w, BAR_H_PCT),
color: green,
});
row_y += BAR_H_PCT + ROW_GAP_PCT;
// Row 2: mostly green.
pills.push(Pill {
rect: RectPct::new(row_x, row_y, row_w * 0.82, BAR_H_PCT),
color: green,
});
row_y += BAR_H_PCT + ROW_GAP_PCT;
// Row 3: yellow.
pills.push(Pill {
rect: RectPct::new(row_x, row_y, row_w * 0.90, BAR_H_PCT),
color: yellow,
});
row_y += BAR_H_PCT + ROW_GAP_PCT;
// Row 4: shorter green.
pills.push(Pill {
rect: RectPct::new(row_x, row_y, row_w * 0.28, BAR_H_PCT),
color: green,
});
Align::new(OnboardingVisual::new(panel_background, pills, true).finish()).finish()
}
@@ -0,0 +1,55 @@
use pathfinder_color::ColorU;
use warpui::elements::Align;
use warpui::Element;
use crate::visuals::onboarding_visual::Rect;
use super::onboarding_visual::{OnboardingVisual, Pill, RectPct};
pub(crate) fn intention_terminal_visual(
panel_background: ColorU,
neutral: ColorU,
neutral_highlight: ColorU,
accent: ColorU,
) -> Box<dyn Element> {
// X is in percent of the inner panel width; negative values intentionally protrude.
const LEFT_PROTRUSION_PCT: f32 = -0.06;
const BAR_H_PCT: f32 = 0.040;
// ROW_GAP_PCT: f32 = 0.012; <- This is the gap between rows.
// All bars are neutral_4 and overhang to the left.
let rows = [
(0.25, 0.15),
(0.50, 0.202),
(0.35, 0.254),
(0.45, 0.306),
(0.70, 0.386),
(0.50, 0.438),
(0.80, 0.490),
];
let mut pills = rows
.into_iter()
.map(|(w_pct, y_pct)| Pill {
rect: RectPct::new(LEFT_PROTRUSION_PCT, y_pct, w_pct, BAR_H_PCT),
color: neutral,
})
.collect::<Vec<_>>();
pills.push(Pill {
rect: RectPct::new(LEFT_PROTRUSION_PCT, 0.08, 0.6, BAR_H_PCT),
color: neutral_highlight,
});
let rects = vec![Rect {
rect: RectPct::new(0.75, 0.481, 0.01, 0.058),
color: accent,
}];
Align::new(
OnboardingVisual::new(panel_background, pills, false)
.with_rects(rects)
.finish(),
)
.finish()
}
@@ -0,0 +1,87 @@
use pathfinder_color::ColorU;
use warpui::elements::Align;
use warpui::Element;
use super::onboarding_visual::{OnboardingVisual, Pill, RectPct};
pub(crate) fn intention_visual(
panel_background: ColorU,
neutral: ColorU,
blue: ColorU,
green: ColorU,
yellow: ColorU,
) -> Box<dyn Element> {
// X is in percent of the inner panel width; negative values intentionally protrude.
const LEFT_PROTRUSION_PCT: f32 = -0.06;
const RIGHT_PROTRUSION_PCT: f32 = 0.03;
const BAR_H_PCT: f32 = 0.040;
let mut pills = Vec::new();
// Top-left neutral bars.
let top_left = [(0.38, 0.18), (0.55, 0.24), (0.42, 0.30), (0.48, 0.36)];
pills.extend(top_left.into_iter().map(|(w_pct, y_pct)| Pill {
rect: RectPct::new(LEFT_PROTRUSION_PCT, y_pct, w_pct, BAR_H_PCT),
color: neutral,
}));
// Upper-right blue bars.
let blue_bars = [(0.26, 0.12), (0.16, 0.18), (0.34, 0.48), (0.26, 0.54)];
pills.extend(blue_bars.into_iter().map(|(w_pct, y_pct)| Pill {
rect: RectPct::new(1.0 - w_pct + RIGHT_PROTRUSION_PCT, y_pct, w_pct, BAR_H_PCT),
color: blue,
}));
// Mid section.
pills.push(Pill {
rect: RectPct::new(LEFT_PROTRUSION_PCT, 0.60, 0.70, BAR_H_PCT),
color: neutral,
});
pills.push(Pill {
rect: RectPct::new(LEFT_PROTRUSION_PCT, 0.66, 0.52, BAR_H_PCT),
color: neutral,
});
pills.push(Pill {
rect: RectPct::new(LEFT_PROTRUSION_PCT, 0.60, 0.32, BAR_H_PCT),
color: neutral,
});
// Bottom container + rows.
const BOTTOM_Y_PCT: f32 = 0.80;
const ROW_GAP_PCT: f32 = 0.012;
let bottom_w = 1.0 + (-LEFT_PROTRUSION_PCT) + RIGHT_PROTRUSION_PCT;
let row_x = LEFT_PROTRUSION_PCT + 0.02;
let row_w = bottom_w - 0.04;
let mut row_y = BOTTOM_Y_PCT + 0.03;
// Row 1: full green.
pills.push(Pill {
rect: RectPct::new(row_x, row_y, row_w, BAR_H_PCT),
color: green,
});
row_y += BAR_H_PCT + ROW_GAP_PCT;
// Row 2: mostly green.
pills.push(Pill {
rect: RectPct::new(row_x, row_y, row_w * 0.82, BAR_H_PCT),
color: green,
});
row_y += BAR_H_PCT + ROW_GAP_PCT;
// Row 3: yellow.
pills.push(Pill {
rect: RectPct::new(row_x, row_y, row_w * 0.90, BAR_H_PCT),
color: yellow,
});
row_y += BAR_H_PCT + ROW_GAP_PCT;
// Row 4: shorter green.
pills.push(Pill {
rect: RectPct::new(row_x, row_y, row_w * 0.28, BAR_H_PCT),
color: green,
});
Align::new(OnboardingVisual::new(panel_background, pills, true).finish()).finish()
}
+12
View File
@@ -0,0 +1,12 @@
mod agent_visual;
mod intention_terminal_visual;
mod intention_visual;
mod onboarding_visual;
mod project_visual;
mod theme_picker_mock_visual;
pub(crate) use agent_visual::agent_visual;
pub(crate) use intention_terminal_visual::intention_terminal_visual;
pub(crate) use intention_visual::intention_visual;
pub(crate) use project_visual::project_visual;
pub(crate) use theme_picker_mock_visual::theme_picker_visual;
@@ -0,0 +1,358 @@
use pathfinder_color::ColorU;
use warp_core::ui::Icon;
use warpui::assets::asset_cache::{AssetCache, AssetSource, AssetState};
use warpui::geometry::rect::RectF;
use warpui::geometry::vector::{vec2f, Vector2F};
use warpui::image_cache::{AnimatedImageBehavior, CacheOption, FitType, Image, ImageCache};
use warpui::{
elements::{CornerRadius, Fill, Point, Radius},
event::DispatchedEvent,
AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext,
SingletonEntity as _, SizeConstraint,
};
#[derive(Debug, Clone, Copy)]
pub(crate) struct RectPct {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
impl RectPct {
pub const fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
Self { x, y, w, h }
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct Pill {
pub rect: RectPct,
pub color: ColorU,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct Rect {
pub rect: RectPct,
pub color: ColorU,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct IconPct {
pub icon: Icon,
pub color: ColorU,
pub center_x: f32,
pub center_y: f32,
pub width_pct: f32,
}
pub(crate) struct OnboardingVisual {
panel_background: ColorU,
pills: Vec<Pill>,
rects: Vec<Rect>,
icons: Vec<IconPct>,
terminal_box: bool,
size: Option<Vector2F>,
origin: Option<Point>,
}
impl OnboardingVisual {
// Padding scale parameters (based on max(x, y) in pixels).
const MIN_PADDING_SIZE_PX: f32 = 500.0;
const MAX_PADDING_SIZE_PX: f32 = 2000.0;
const MIN_PADDING_PCT: f32 = 0.10;
const MAX_PADDING_PCT: f32 = 0.30;
pub fn new(panel_background: ColorU, pills: Vec<Pill>, terminal_box: bool) -> Self {
Self::new_internal(panel_background, pills, terminal_box)
}
fn new_internal(panel_background: ColorU, pills: Vec<Pill>, terminal_box: bool) -> Self {
Self {
panel_background,
pills,
icons: Vec::new(),
rects: Vec::new(),
terminal_box,
size: None,
origin: None,
}
}
pub fn with_icons(mut self, icons: Vec<IconPct>) -> Self {
self.icons = icons;
self
}
pub fn with_rects(mut self, rects: Vec<Rect>) -> Self {
self.rects = rects;
self
}
pub fn compute_contained_size(constraint: SizeConstraint) -> Vector2F {
// Maintain the old images aspect ratio so the right-side layout stays stable.
const ASPECT_RATIO: f32 = 1.5;
let max = constraint.max;
if max.x().is_infinite() && max.y().is_infinite() {
return vec2f(constraint.min.x().max(0.), constraint.min.y().max(0.));
}
let mut width = if max.x().is_finite() {
max.x().max(0.)
} else {
(max.y().max(0.)) * ASPECT_RATIO
};
let mut height = if max.y().is_finite() {
max.y().max(0.)
} else {
width / ASPECT_RATIO
};
// Contain within both axes when both are finite.
if max.x().is_finite() && max.y().is_finite() {
height = width / ASPECT_RATIO;
if height > max.y() {
height = max.y();
width = height * ASPECT_RATIO;
}
}
vec2f(width.max(1.), height.max(1.))
}
fn feature_radius_px(inner_height: f32) -> f32 {
// All features use the same radius.
inner_height * 0.02
}
fn draw_pill(
&self,
rect: RectF,
color: ColorU,
feature_radius_px: f32,
ctx: &mut PaintContext,
) {
ctx.scene
.draw_rect_with_hit_recording(rect)
.with_background(Fill::Solid(color))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(feature_radius_px)));
}
fn draw_rect(&self, rect: RectF, color: ColorU, ctx: &mut PaintContext) {
ctx.scene
.draw_rect_with_hit_recording(rect)
.with_background(Fill::Solid(color));
}
fn rect_from_pct(inner_origin: Vector2F, inner_size: Vector2F, rect: RectPct) -> RectF {
RectF::new(
vec2f(
inner_origin.x() + inner_size.x() * rect.x,
inner_origin.y() + inner_size.y() * rect.y,
),
vec2f(inner_size.x() * rect.w, inner_size.y() * rect.h),
)
}
fn icon_rect_from_pct(
inner_origin: Vector2F,
inner_size: Vector2F,
center_x: f32,
center_y: f32,
width_pct: f32,
) -> RectF {
let center = vec2f(
inner_origin.x() + inner_size.x() * center_x,
inner_origin.y() + inner_size.y() * center_y,
);
let width_px = inner_size.x() * width_pct;
RectF::new(
center - vec2f(width_px / 2.0, width_px / 2.0),
vec2f(width_px, width_px),
)
}
fn draw_icon(
&self,
rect: RectF,
icon: Icon,
color: ColorU,
ctx: &mut PaintContext,
app: &AppContext,
) {
let bounds = (rect.size() * ctx.scene.scale_factor()).to_i32();
if bounds.x() <= 0 || bounds.y() <= 0 {
return;
}
let path: &'static str = icon.into();
let asset_cache = AssetCache::as_ref(app);
match ImageCache::as_ref(app).image(
AssetSource::Bundled { path },
bounds,
FitType::Contain,
AnimatedImageBehavior::FullAnimation,
CacheOption::BySize,
ctx.max_texture_dimension_2d,
asset_cache,
) {
AssetState::Loaded { data } => match data.as_ref() {
Image::Static(image) => {
let logical_image_size = image.size().to_f32() / ctx.scene.scale_factor();
let origin = rect.origin() + ((rect.size() - logical_image_size) / 2.0);
ctx.scene.draw_icon(
RectF::new(origin, logical_image_size),
image.clone(),
1.0,
color,
);
}
Image::Animated(_) => {
log::info!("Animated icons are currently not supported");
}
},
AssetState::Loading { handle } => {
ctx.repaint_after_load(handle);
}
AssetState::Evicted => {
log::warn!("Unable to render svg because it was evicted");
}
AssetState::FailedToLoad(err) => {
log::warn!("Unable to render svg: {err:#}");
}
}
}
}
impl Element for OnboardingVisual {
fn layout(
&mut self,
constraint: SizeConstraint,
_ctx: &mut LayoutContext,
_app: &AppContext,
) -> Vector2F {
let size = Self::compute_contained_size(constraint);
self.size = Some(size);
size
}
fn after_layout(&mut self, _ctx: &mut AfterLayoutContext, _app: &AppContext) {}
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, _app: &AppContext) {
let Some(size) = self.size else {
return;
};
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
let max_dimension = size.x().max(size.y());
// Scale padding linearly with size.
let padding_pct = if max_dimension <= Self::MIN_PADDING_SIZE_PX {
Self::MIN_PADDING_PCT
} else if max_dimension >= Self::MAX_PADDING_SIZE_PX {
Self::MAX_PADDING_PCT
} else {
let t = (max_dimension - Self::MIN_PADDING_SIZE_PX)
/ (Self::MAX_PADDING_SIZE_PX - Self::MIN_PADDING_SIZE_PX);
Self::MIN_PADDING_PCT + t * (Self::MAX_PADDING_PCT - Self::MIN_PADDING_PCT)
};
let padding_x: f32 = padding_pct * size.x();
let padding_y: f32 = padding_pct * size.y();
let inner_origin = origin + vec2f(padding_x, padding_y);
let inner_size = vec2f(
(size.x() - 2.0 * padding_x).max(1.),
(size.y() - 2.0 * padding_y).max(1.),
);
let feature_radius_px = Self::feature_radius_px(inner_size.y());
// Background panel.
ctx.scene
.draw_rect_with_hit_recording(RectF::new(inner_origin, inner_size))
.with_background(Fill::Solid(self.panel_background))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(feature_radius_px)));
if self.terminal_box {
// Bottom container behind the final rows.
const TERMINAL_BOX_Y_PCT: f32 = 0.80;
const TERMINAL_BOX_H_PCT: f32 = 0.25;
let min_x = self
.pills
.iter()
.map(|pill| pill.rect.x)
.fold(0.0_f32, f32::min);
let max_x = self
.pills
.iter()
.map(|pill| pill.rect.x + pill.rect.w)
.fold(1.0_f32, f32::max);
let box_rect = Self::rect_from_pct(
inner_origin,
inner_size,
RectPct::new(min_x, TERMINAL_BOX_Y_PCT, max_x - min_x, TERMINAL_BOX_H_PCT),
);
let box_color = self
.pills
.first()
.map(|pill| pill.color)
.unwrap_or(self.panel_background);
ctx.scene
.draw_rect_with_hit_recording(box_rect)
.with_background(Fill::Solid(box_color))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(feature_radius_px)));
}
// Rects.
for rect in &self.rects {
let rect_px = Self::rect_from_pct(inner_origin, inner_size, rect.rect);
self.draw_rect(rect_px, rect.color, ctx);
}
// Pills.
for pill in &self.pills {
let rect = Self::rect_from_pct(inner_origin, inner_size, pill.rect);
self.draw_pill(rect, pill.color, feature_radius_px, ctx);
}
// Icons.
for icon in &self.icons {
let rect = Self::icon_rect_from_pct(
inner_origin,
inner_size,
icon.center_x,
icon.center_y,
icon.width_pct,
);
self.draw_icon(rect, icon.icon, icon.color, ctx, _app);
}
}
fn dispatch_event(
&mut self,
_event: &DispatchedEvent,
_ctx: &mut EventContext,
_app: &AppContext,
) -> bool {
false
}
fn size(&self) -> Option<Vector2F> {
self.size
}
fn origin(&self) -> Option<Point> {
self.origin
}
}
@@ -0,0 +1,78 @@
use pathfinder_color::ColorU;
use warp_core::ui::Icon;
use warpui::elements::Align;
use warpui::Element;
use super::onboarding_visual::{IconPct, OnboardingVisual, Pill, RectPct};
pub(crate) fn project_visual(
panel_background: ColorU,
pill_color: ColorU,
center_icon_color: ColorU,
side_icon_color: ColorU,
) -> Box<dyn Element> {
const PILL_W_PCT: f32 = 0.23;
const PILL_H_PCT: f32 = 0.34;
let x = (1.0 - PILL_W_PCT) / 2.0;
let y = (1.0 - PILL_H_PCT) / 2.0;
let pills = vec![Pill {
rect: RectPct::new(x, y, PILL_W_PCT, PILL_H_PCT),
color: pill_color,
}];
// Five folder icons, all vertically centered.
// - 1 centered in the middle, fitting within the pill.
// - 2 on each side, 70% the size of the center icon.
// - furthest left/right centers are at 0% and 100%.
const CENTER_Y: f32 = 0.5;
let center_icon_w = PILL_W_PCT * 0.8;
let side_icon_w = center_icon_w * 0.7;
let icons = vec![
IconPct {
icon: Icon::Folder,
color: side_icon_color,
center_x: 0.0,
center_y: CENTER_Y,
width_pct: side_icon_w,
},
IconPct {
icon: Icon::Folder,
color: side_icon_color,
center_x: 0.25,
center_y: CENTER_Y,
width_pct: side_icon_w,
},
IconPct {
icon: Icon::Folder,
color: center_icon_color,
center_x: 0.5,
center_y: CENTER_Y,
width_pct: center_icon_w,
},
IconPct {
icon: Icon::Folder,
color: side_icon_color,
center_x: 0.75,
center_y: CENTER_Y,
width_pct: side_icon_w,
},
IconPct {
icon: Icon::Folder,
color: side_icon_color,
center_x: 1.0,
center_y: CENTER_Y,
width_pct: side_icon_w,
},
];
Align::new(
OnboardingVisual::new(panel_background, pills, false)
.with_icons(icons)
.finish(),
)
.finish()
}
@@ -0,0 +1,77 @@
use super::onboarding_visual::{OnboardingVisual, Pill, RectPct};
use warp_core::ui::{appearance::Appearance, theme::color::internal_colors};
use warpui::{elements::Align, Element};
pub(crate) fn theme_picker_visual(appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let panel_background = internal_colors::neutral_2(theme);
let neutral = internal_colors::neutral_4(theme);
let yellow = theme.ansi_fg_yellow();
let red = theme.ansi_fg_red();
let blue = theme.ansi_fg_blue();
let green = theme.ansi_fg_green();
let magenta = theme.ansi_fg_magenta();
// X is in percent of the inner panel width; negative values intentionally protrude.
const LEFT_PROTRUSION_PCT: f32 = -0.06;
const BAR_H_PCT: f32 = 0.038;
let pills = [
// Top-left neutral bars.
Pill {
rect: RectPct::new(LEFT_PROTRUSION_PCT, 0.18, 0.30, BAR_H_PCT),
color: neutral,
},
Pill {
rect: RectPct::new(LEFT_PROTRUSION_PCT, 0.24, 0.50, BAR_H_PCT),
color: neutral,
},
Pill {
rect: RectPct::new(LEFT_PROTRUSION_PCT, 0.30, 0.36, BAR_H_PCT),
color: neutral,
},
Pill {
rect: RectPct::new(LEFT_PROTRUSION_PCT, 0.36, 0.42, BAR_H_PCT),
color: neutral,
},
// Upper-right colored bars.
Pill {
rect: RectPct::new(0.52 + LEFT_PROTRUSION_PCT, 0.24, 0.28, BAR_H_PCT),
color: yellow,
},
Pill {
rect: RectPct::new(0.44 + LEFT_PROTRUSION_PCT, 0.36, 0.14, BAR_H_PCT),
color: red,
},
// Mid section.
Pill {
rect: RectPct::new(LEFT_PROTRUSION_PCT, 0.62, 0.68, BAR_H_PCT),
color: blue,
},
Pill {
rect: RectPct::new(0.70 + LEFT_PROTRUSION_PCT, 0.62, 0.18, BAR_H_PCT),
color: green,
},
Pill {
rect: RectPct::new(LEFT_PROTRUSION_PCT, 0.68, 0.40, BAR_H_PCT),
color: neutral,
},
// Bottom section.
Pill {
rect: RectPct::new(LEFT_PROTRUSION_PCT, 0.80, 0.44, BAR_H_PCT),
color: neutral,
},
Pill {
rect: RectPct::new(LEFT_PROTRUSION_PCT, 0.86, 0.28, BAR_H_PCT),
color: neutral,
},
Pill {
rect: RectPct::new(0.30 + LEFT_PROTRUSION_PCT, 0.86, 0.30, BAR_H_PCT),
color: magenta,
},
];
Align::new(OnboardingVisual::new(panel_background, pills.to_vec(), false).finish()).finish()
}