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            ("custom", "Other model (specify)"),
222        ],
223        Runner::Gemini => vec![
224            (
225                "zai-coding-plan/glm-4.7",
226                "Default Gemini model (recommended)",
227            ),
228            ("custom", "Other model (specify)"),
229        ],
230        Runner::Opencode => vec![
231            ("zai-coding-plan/glm-4.7", "GLM-4.7 model (recommended)"),
232            ("custom", "Other model (specify)"),
233        ],
234        Runner::Kimi => vec![
235            ("kimi-for-coding", "Kimi coding model (recommended)"),
236            ("custom", "Other model (specify)"),
237        ],
238        Runner::Pi => vec![
239            ("gpt-5.3", "GPT-5.3 model (recommended)"),
240            ("custom", "Other model (specify)"),
241        ],
242        Runner::Cursor => vec![
243            ("auto", "Let Cursor choose automatically (recommended)"),
244            ("custom", "Other model (specify)"),
245        ],
246        Runner::Plugin(_) => vec![
247            ("default", "Use runner default"),
248            ("custom", "Specify custom model"),
249        ],
250    };
251
252    let items: Vec<String> = models
253        .iter()
254        .map(|(name, desc)| format!("{} - {}", name, desc))
255        .collect();
256
257    let idx = Select::new()
258        .with_prompt("Select model")
259        .items(&items)
260        .default(0)
261        .interact()
262        .context("failed to get model selection")?;
263
264    let selected = models[idx].0;
265
266    if selected == "custom" {
267        let custom: String = Input::new()
268            .with_prompt("Enter model name")
269            .allow_empty(false)
270            .interact_text()
271            .context("failed to get custom model")?;
272        Ok(custom)
273    } else {
274        Ok(selected.to_string())
275    }
276}
277
278/// Select the number of phases with explanations.
279fn select_phases() -> Result<u8> {
280    let phase_options = [
281        (
282            "3-phase (Full)",
283            "Plan → Implement + CI → Review + Complete [Recommended]",
284        ),
285        (
286            "2-phase (Standard)",
287            "Plan → Implement (faster, less review)",
288        ),
289        (
290            "1-phase (Quick)",
291            "Single-pass execution (simple fixes only)",
292        ),
293    ];
294
295    let items: Vec<String> = phase_options
296        .iter()
297        .map(|(name, desc)| format!("{} - {}", name, desc))
298        .collect();
299
300    let idx = Select::new()
301        .with_prompt("Select workflow mode")
302        .items(&items)
303        .default(0)
304        .interact()
305        .context("failed to get phase selection")?;
306
307    Ok(match idx {
308        0 => 3,
309        1 => 2,
310        2 => 1,
311        _ => 3,
312    })
313}
314
315/// Print a summary of the wizard answers.
316fn print_summary(answers: &WizardAnswers) {
317    println!();
318    println!("{}", colored::Colorize::bold("Setup Summary:"));
319    println!("{}", colored::Colorize::bright_black("──────────────"));
320    println!(
321        "Runner: {} ({})",
322        colored::Colorize::bright_green(format!("{:?}", answers.runner).as_str()),
323        answers.model
324    );
325    println!(
326        "Workflow: {}-phase",
327        colored::Colorize::bright_green(format!("{}", answers.phases).as_str())
328    );
329
330    if answers.create_first_task {
331        if let Some(ref title) = answers.first_task_title {
332            println!(
333                "First Task: {}",
334                colored::Colorize::bright_green(title.as_str())
335            );
336        }
337    } else {
338        println!("First Task: {}", colored::Colorize::bright_black("(none)"));
339    }
340
341    println!();
342    println!("Files to create:");
343    println!("  - .ralph/config.jsonc");
344    println!("  - .ralph/queue.jsonc");
345    println!("  - .ralph/done.jsonc");
346    println!();
347}
348
349/// Print completion message with next steps.
350pub fn print_completion_message(answers: Option<&WizardAnswers>, _queue_path: &Path) {
351    println!();
352    println!(
353        "{}",
354        colored::Colorize::bright_green("✓ Ralph initialized successfully!")
355    );
356    println!();
357    println!("{}", colored::Colorize::bold("Next steps:"));
358    println!("  1. Run 'ralph app open' to open the macOS app (optional)");
359    println!("  2. Run 'ralph run one' to execute your first task");
360    println!("  3. Edit .ralph/config.jsonc to customize settings");
361
362    if let Some(answers) = answers
363        && answers.create_first_task
364    {
365        println!();
366        println!("Your first task is ready to go!");
367    }
368
369    println!();
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn wizard_answers_default() {
378        let answers = WizardAnswers::default();
379        assert_eq!(answers.runner, Runner::Claude);
380        assert_eq!(answers.model, "sonnet");
381        assert_eq!(answers.phases, 3);
382        assert!(!answers.create_first_task);
383        assert!(answers.first_task_title.is_none());
384        assert!(answers.first_task_description.is_none());
385        assert_eq!(answers.first_task_priority, TaskPriority::Medium);
386    }
387}