Files
galaxy/crates/onboarding/src/callout/view.rs
T

489 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use galaxy_core::ui::appearance::Appearance;
use galaxyui_core::elements::Empty;
use galaxyui_core::keymap::macros::*;
use galaxyui_core::keymap::{FixedBinding, Keystroke};
use galaxyui_core::{
AppContext, Element, Entity, EventContext, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext,
};
use ui_components::Component;
/// Display strings for keybindings shown in the onboarding callout.
#[derive(Clone, Debug)]
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,
/// Display string for returning to terminal mode (e.g., "Esc")
pub return_to_terminal_mode: String,
}
use crate::callout::model::{
AgentModalityCalloutState, FinalState, OnboardingCalloutModel, OnboardingCalloutModelEvent,
OnboardingCalloutState, OnboardingQuery, UniversalInputCalloutState,
};
use crate::components::onboarding_callout::{self, Button, StepStatus};
use crate::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 => 1,
OnboardingIntention::AgentDrivenDevelopment => 2,
};
match state {
AgentModalityCalloutState::TerminalMode => {
let is_final_step = intention == OnboardingIntention::Terminal;
// Show different callout content based on initial NL detection state
if initial_natural_language_detection_enabled {
// NL detection was already enabled - show simpler "overrides" callout without checkbox
Some(CalloutOptions {
title: "Welcome to terminal mode",
text: format!(
"Run commands here, just like a regular terminal. If you type a question or task using natural language, Warp can suggest opening it in agent mode. You can always override using {}.",
keybindings.toggle_input_mode
),
step: StepStatus::new(0, 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: "Youre in terminal mode",
text: format!(
"Run commands here, just like a regular terminal. If you type a question or task using natural language, Warp can suggest opening it in agent mode. You can always override using {}.",
keybindings.toggle_input_mode
),
step: StepStatus::new(0, 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::AgentMode => {
if has_project {
Some(CalloutOptions {
title: "You're in agent mode",
text: "Agent mode gives your questions and tasks their own conversation, so you can ask follow-ups without leaving your terminal workflow.\n\nSubmit the query below to have the agent initialize this project, or ⊗ to clear the input and start your own!".to_string(),
step: StepStatus::new(1, total_steps),
left_button: Some(ButtonOptions {
text: "Skip initialization",
action: OnboardingCalloutViewAction::SkipClicked,
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: "You're in agent mode",
text: format!(
"Agent mode gives your questions and tasks their own conversation, so you can ask follow-ups without leaving your terminal workflow. Press {} to return to terminal mode at any point.",
keybindings.return_to_terminal_mode
),
step: StepStatus::new(1, total_steps),
left_button: Some(ButtonOptions {
text: "Back to terminal",
action: OnboardingCalloutViewAction::BackToTerminalClicked,
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 the agent experience state, always position relative to the input box instead.
pub fn should_position_above_zero_state(&self, app: &AppContext) -> bool {
!matches!(
self.model.as_ref(app).state(),
OnboardingCalloutState::AgentModality(AgentModalityCalloutState::AgentMode)
)
}
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 {}