488 lines
18 KiB
Rust
488 lines
18 KiB
Rust
use crate::telemetry::OnboardingEvent;
|
|
use crate::OnboardingIntention;
|
|
use galaxy_core::send_telemetry_from_ctx;
|
|
use galaxyui::{Entity, ModelContext};
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum FinalState {
|
|
/// 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;
|
|
}
|