Skip to main content

ralph/commands/init/
wizard.rs

1//! Interactive onboarding wizard for Ralph initialization.
2//!
3//! Responsibilities:
4//! - Display welcome screen and collect user preferences.
5//! - Guide users through runner, model, and phase selection.
6//! - Optionally create a first task during setup.
7//!
8//! Not handled here:
9//! - File creation (see `super::writers`).
10//! - CLI argument parsing (handled by CLI layer).
11//!
12//! Invariants/assumptions:
13//! - Wizard is only run in interactive TTY environments.
14//! - User inputs are validated before returning WizardAnswers.
15
16use crate::contracts::{Runner, TaskPriority};
17use anyhow::{Context, Result};
18use dialoguer::{Confirm, Input, Select};
19use std::path::Path;
20
21/// Answers collected from the interactive wizard.
22#[derive(Debug, Clone)]
23pub struct WizardAnswers {
24    /// Selected AI runner.
25    pub runner: Runner,
26    /// Selected model (as string for flexibility).
27    pub model: String,
28    /// Number of phases (1, 2, or 3).
29    pub phases: u8,
30    /// Whether to create a first task.
31    pub create_first_task: bool,
32    /// Title for the first task (if created).
33    pub first_task_title: Option<String>,
34    /// Description/request for the first task (if created).
35    pub first_task_description: Option<String>,
36    /// Priority for the first task.
37    pub first_task_priority: TaskPriority,
38}
39
40impl Default for WizardAnswers {
41    fn default() -> Self {
42        Self {
43            runner: Runner::Claude,
44            model: "sonnet".to_string(),
45            phases: 3,
46            create_first_task: false,
47            first_task_title: None,
48            first_task_description: None,
49            first_task_priority: TaskPriority::Medium,
50        }
51    }
52}
53
54/// Run the interactive onboarding wizard and collect user preferences.
55pub fn run_wizard() -> Result<WizardAnswers> {
56    // Welcome screen
57    print_welcome();
58
59    // Runner selection
60    let runners = [
61        (
62            "Claude",
63            "Anthropic's Claude Code CLI - Best for complex reasoning",
64        ),
65        ("Codex", "OpenAI's Codex CLI - Great for code generation"),
66        ("OpenCode", "OpenCode agent - Open source alternative"),
67        (
68            "Gemini",
69            "Google's Gemini CLI - Good for large context windows",
70        ),
71        ("Cursor", "Cursor's agent mode - IDE-integrated workflow"),
72        ("Kimi", "Moonshot AI Kimi - Strong coding capabilities"),
73        ("Pi", "Inflection Pi - Conversational AI assistant"),
74    ];
75
76    let runner_idx = Select::new()
77        .with_prompt("Select your AI runner")
78        .items(
79            runners
80                .iter()
81                .map(|(name, desc)| format!("{} - {}", name, desc))
82                .collect::<Vec<_>>(),
83        )
84        .default(0)
85        .interact()
86        .context("failed to get runner selection")?;
87
88    let runner = match runner_idx {
89        0 => Runner::Claude,
90        1 => Runner::Codex,
91        2 => Runner::Opencode,
92        3 => Runner::Gemini,
93        4 => Runner::Cursor,
94        5 => Runner::Kimi,
95        6 => Runner::Pi,
96        _ => Runner::Claude, // default fallback
97    };
98
99    // Model selection based on runner
100    let model = select_model(&runner)?;
101
102    // Phase selection
103    let phases = select_phases()?;
104
105    // First task creation
106    let create_first_task = Confirm::new()
107        .with_prompt("Would you like to create your first task now?")
108        .default(true)
109        .interact()
110        .context("failed to get first task confirmation")?;
111
112    let (first_task_title, first_task_description, first_task_priority) = if create_first_task {
113        let title: String = Input::new()
114            .with_prompt("Task title")
115            .allow_empty(false)
116            .interact_text()
117            .context("failed to get task title")?;
118
119        let description: String = Input::new()
120            .with_prompt("Task description (what should be done)")
121            .allow_empty(true)
122            .interact_text()
123            .context("failed to get task description")?;
124
125        let priorities = vec!["Low", "Medium", "High", "Critical"];
126        let priority_idx = Select::new()
127            .with_prompt("Task priority")
128            .items(&priorities)
129            .default(1)
130            .interact()
131            .context("failed to get priority selection")?;
132
133        let priority = match priority_idx {
134            0 => TaskPriority::Low,
135            1 => TaskPriority::Medium,
136            2 => TaskPriority::High,
137            3 => TaskPriority::Critical,
138            _ => TaskPriority::Medium,
139        };
140
141        (Some(title), Some(description), priority)
142    } else {
143        (None, None, TaskPriority::Medium)
144    };
145
146    // Summary and confirmation
147    let answers = WizardAnswers {
148        runner,
149        model,
150        phases,
151        create_first_task,
152        first_task_title,
153        first_task_description,
154        first_task_priority,
155    };
156
157    print_summary(&answers);
158
159    let proceed = Confirm::new()
160        .with_prompt("Proceed with setup?")
161        .default(true)
162        .interact()
163        .context("failed to get confirmation")?;
164
165    if !proceed {
166        anyhow::bail!("Setup cancelled by user");
167    }
168
169    Ok(answers)
170}
171
172/// Print the welcome screen with ASCII art.
173fn print_welcome() {
174    println!();
175    println!(
176        "{}",
177        colored::Colorize::bright_cyan(r"    ____       __        __")
178    );
179    println!(
180        "{}",
181        colored::Colorize::bright_cyan(r"   / __ \___  / /_____  / /_____ ___")
182    );
183    println!(
184        "{}",
185        colored::Colorize::bright_cyan(r"  / /_/ / _ \/ __/ __ \/ __/ __ `__ \ ")
186    );
187    println!(
188        "{}",
189        colored::Colorize::bright_cyan(r" / _, _/  __/ /_/ /_/ / /_/ / / / / /")
190    );
191    println!(
192        "{}",
193        colored::Colorize::bright_cyan(r"/_/ |_|\___/\__/ .___/\__/_/ /_/ /_/")
194    );
195    println!("{}", colored::Colorize::bright_cyan(r"             /_/"));
196    println!();
197    println!("{}", colored::Colorize::bold("Welcome to Ralph!"));
198    println!();
199    println!("Ralph is an AI task queue for structured agent workflows.");
200    println!("This wizard will help you set up your project and create your first task.");
201    println!();
202}
203
204/// Select model based on the chosen runner.
205fn select_model(runner: &Runner) -> Result<String> {
206    let models: Vec<(&str, &str)> = match runner {
207        Runner::Claude => vec![
208            ("sonnet", "Balanced speed and intelligence (recommended)"),
209            ("opus", "Most powerful, best for complex tasks"),
210            ("haiku", "Fastest, good for simple tasks"),
211            ("custom", "Other model (specify)"),
212        ],
213        Runner::Codex => vec![
214            (
215                "gpt-5.4",
216                "Latest general GPT-5 model for Codex (recommended)",
217            ),
218            ("gpt-5.3-codex", "Codex optimized for coding"),
219            ("gpt-5.3-codex-spark", "Codex Spark variant for coding"),
220            ("gpt-5.3", "General GPT-5.3"),
221            ("gpt-5.2-codex", "Codex optimized for coding (legacy)"),
222            ("gpt-5.2", "General GPT-5.2 (legacy)"),
223            ("custom", "Other model (specify)"),
224        ],
225        Runner::Gemini => vec![
226            (
227                "zai-coding-plan/glm-4.7",
228                "Default Gemini model (recommended)",
229            ),
230            ("custom", "Other model (specify)"),
231        ],
232        Runner::Opencode => vec![
233            ("zai-coding-plan/glm-4.7", "GLM-4.7 model (recommended)"),
234            ("custom", "Other model (specify)"),
235        ],
236        Runner::Kimi => vec![
237            ("kimi-for-coding", "Kimi coding model (recommended)"),
238            ("custom", "Other model (specify)"),
239        ],
240        Runner::Pi => vec![
241            ("gpt-5.3", "GPT-5.3 model (recommended)"),
242            ("custom", "Other model (specify)"),
243        ],
244        Runner::Cursor => vec![
245            ("auto", "Let Cursor choose automatically (recommended)"),
246            ("custom", "Other model (specify)"),
247        ],
248        Runner::Plugin(_) => vec![
249            ("default", "Use runner default"),
250            ("custom", "Specify custom model"),
251        ],
252    };
253
254    let items: Vec<String> = models
255        .iter()
256        .map(|(name, desc)| format!("{} - {}", name, desc))
257        .collect();
258
259    let idx = Select::new()
260        .with_prompt("Select model")
261        .items(&items)
262        .default(0)
263        .interact()
264        .context("failed to get model selection")?;
265
266    let selected = models[idx].0;
267
268    if selected == "custom" {
269        let custom: String = Input::new()
270            .with_prompt("Enter model name")
271            .allow_empty(false)
272            .interact_text()
273            .context("failed to get custom model")?;
274        Ok(custom)
275    } else {
276        Ok(selected.to_string())
277    }
278}
279
280/// Select the number of phases with explanations.
281fn select_phases() -> Result<u8> {
282    let phase_options = [
283        (
284            "3-phase (Full)",
285            "Plan → Implement + CI → Review + Complete [Recommended]",
286        ),
287        (
288            "2-phase (Standard)",
289            "Plan → Implement (faster, less review)",
290        ),
291        (
292            "1-phase (Quick)",
293            "Single-pass execution (simple fixes only)",
294        ),
295    ];
296
297    let items: Vec<String> = phase_options
298        .iter()
299        .map(|(name, desc)| format!("{} - {}", name, desc))
300        .collect();
301
302    let idx = Select::new()
303        .with_prompt("Select workflow mode")
304        .items(&items)
305        .default(0)
306        .interact()
307        .context("failed to get phase selection")?;
308
309    Ok(match idx {
310        0 => 3,
311        1 => 2,
312        2 => 1,
313        _ => 3,
314    })
315}
316
317/// Print a summary of the wizard answers.
318fn print_summary(answers: &WizardAnswers) {
319    println!();
320    println!("{}", colored::Colorize::bold("Setup Summary:"));
321    println!("{}", colored::Colorize::bright_black("──────────────"));
322    println!(
323        "Runner: {} ({})",
324        colored::Colorize::bright_green(format!("{:?}", answers.runner).as_str()),
325        answers.model
326    );
327    println!(
328        "Workflow: {}-phase",
329        colored::Colorize::bright_green(format!("{}", answers.phases).as_str())
330    );
331
332    if answers.create_first_task {
333        if let Some(ref title) = answers.first_task_title {
334            println!(
335                "First Task: {}",
336                colored::Colorize::bright_green(title.as_str())
337            );
338        }
339    } else {
340        println!("First Task: {}", colored::Colorize::bright_black("(none)"));
341    }
342
343    println!();
344    println!("Files to create:");
345    println!("  - .ralph/config.jsonc");
346    println!("  - .ralph/queue.jsonc");
347    println!("  - .ralph/done.jsonc");
348    println!();
349}
350
351/// Print completion message with next steps.
352pub fn print_completion_message(answers: Option<&WizardAnswers>, _queue_path: &Path) {
353    println!();
354    println!(
355        "{}",
356        colored::Colorize::bright_green("✓ Ralph initialized successfully!")
357    );
358    println!();
359    println!("{}", colored::Colorize::bold("Next steps:"));
360    println!("  1. Run 'ralph app open' to open the macOS app (optional)");
361    println!("  2. Run 'ralph run one' to execute your first task");
362    println!("  3. Edit .ralph/config.jsonc to customize settings");
363
364    if let Some(answers) = answers
365        && answers.create_first_task
366    {
367        println!();
368        println!("Your first task is ready to go!");
369    }
370
371    println!();
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn wizard_answers_default() {
380        let answers = WizardAnswers::default();
381        assert_eq!(answers.runner, Runner::Claude);
382        assert_eq!(answers.model, "sonnet");
383        assert_eq!(answers.phases, 3);
384        assert!(!answers.create_first_task);
385        assert!(answers.first_task_title.is_none());
386        assert!(answers.first_task_description.is_none());
387        assert_eq!(answers.first_task_priority, TaskPriority::Medium);
388    }
389}