use super::app::WorkspaceNode;
use super::theme::{THEMES, Theme};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Step {
Intro,
Target,
Agent,
Skills,
Theme,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProjectOption {
pub project_id: String,
pub project_name: String,
pub environment_id: String,
pub environment_name: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Outcome {
pub project: Option<ProjectOption>,
pub agent: String,
pub skills: bool,
pub skills_source: Option<String>,
pub theme: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TargetEnv {
pub id: String,
pub name: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TargetProject {
pub id: String,
pub name: String,
pub expanded: bool,
pub envs: Vec<TargetEnv>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TargetWorkspace {
pub id: String,
pub name: String,
pub expanded: bool,
pub projects: Vec<TargetProject>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum TargetRow {
Workspace(usize),
CreateProject(usize),
Project(usize, usize),
Env(usize, usize, usize),
DecideLater,
}
pub struct Wizard {
pub step: Step,
pub cursor: usize,
pub busy: Option<String>,
pub error: Option<String>,
pub project: Option<ProjectOption>,
pub workspaces: Vec<TargetWorkspace>,
pub agent: usize,
pub skills: bool,
pub skills_source: Option<String>,
pub theme: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Action {
None,
Redraw,
CreateProject(String),
Finish(Box<Outcome>),
Cancel,
}
pub fn project_options(tree: &[WorkspaceNode]) -> Vec<ProjectOption> {
tree.iter()
.flat_map(|ws| ws.projects.iter())
.filter_map(|project| {
let env = project.envs.first()?;
Some(ProjectOption {
project_id: project.id.clone(),
project_name: project.name.clone(),
environment_id: env.id.clone(),
environment_name: env.name.clone(),
})
})
.collect()
}
pub fn harness_blurb(slug: &str) -> &'static str {
match slug {
"claude" => "Anthropic's Claude Code",
"codex" => "OpenAI's Codex",
"grok" => "xAI's Grok",
"railway" => "Railway's own agent — no sign-in needed",
_ => "",
}
}
impl Wizard {
pub fn new(
tree: &[WorkspaceNode],
harness: Option<&str>,
theme: &Theme,
skills_source: Option<String>,
) -> Self {
let mut workspaces: Vec<TargetWorkspace> = tree
.iter()
.map(|ws| TargetWorkspace {
id: ws.id.clone(),
name: ws.name.clone(),
expanded: false,
projects: ws
.projects
.iter()
.map(|project| TargetProject {
id: project.id.clone(),
name: project.name.clone(),
expanded: false,
envs: project
.envs
.iter()
.map(|env| TargetEnv {
id: env.id.clone(),
name: env.name.clone(),
})
.collect(),
})
.collect(),
})
.collect();
if let Some(first) = workspaces.first_mut() {
first.expanded = true;
}
Self {
step: Step::Intro,
cursor: 0,
busy: None,
error: None,
project: None,
workspaces,
agent: harness
.and_then(|h| super::app::HARNESSES.iter().position(|x| *x == h))
.unwrap_or(0),
skills: skills_source.is_some(),
skills_source,
theme: theme.index(),
}
}
fn target_rows(&self) -> Vec<TargetRow> {
let mut rows = Vec::new();
for (w, workspace) in self.workspaces.iter().enumerate() {
rows.push(TargetRow::Workspace(w));
if !workspace.expanded {
continue;
}
rows.push(TargetRow::CreateProject(w));
for (p, project) in workspace.projects.iter().enumerate() {
rows.push(TargetRow::Project(w, p));
if !project.expanded || project.envs.len() == 1 {
continue;
}
for e in 0..project.envs.len() {
rows.push(TargetRow::Env(w, p, e));
}
}
}
rows.push(TargetRow::DecideLater);
rows
}
fn target_row_label(&self, row: TargetRow) -> (String, String) {
match row {
TargetRow::Workspace(w) => {
let workspace = &self.workspaces[w];
let marker = if workspace.expanded { "▾" } else { "▸" };
let count = workspace.projects.len();
(
format!("{marker} {}", workspace.name),
format!("{count} project{}", if count == 1 { "" } else { "s" }),
)
}
TargetRow::CreateProject(w) => (
" + Create a project".into(),
format!(
"a new project named \"Cloud Agents\" in {}",
self.workspaces[w].name
),
),
TargetRow::Project(w, p) => {
let project = &self.workspaces[w].projects[p];
let count = project.envs.len();
if let [env] = project.envs.as_slice() {
return (
format!(" {} ({})", project.name, env.name),
String::new(),
);
}
let marker = if project.expanded { "▾" } else { "▸" };
(
format!(" {marker} {}", project.name),
format!("{count} environment{}", if count == 1 { "" } else { "s" }),
)
}
TargetRow::Env(w, p, e) => {
let env = &self.workspaces[w].projects[p].envs[e];
(format!(" {}", env.name), String::new())
}
TargetRow::DecideLater => (
"Decide later".into(),
"Pick a target each time you launch".into(),
),
}
}
pub fn options(&self) -> Vec<(String, String)> {
match self.step {
Step::Intro => vec![
(
"Set up now".into(),
"Four questions: where agents live, which one to run, your skills, and a theme"
.into(),
),
(
"Not now".into(),
"Everything still works; you will be asked for a target each time".into(),
),
],
Step::Target => self
.target_rows()
.into_iter()
.map(|row| self.target_row_label(row))
.collect(),
Step::Agent => super::app::HARNESSES
.iter()
.map(|slug| ((*slug).to_string(), harness_blurb(slug).to_string()))
.collect(),
Step::Skills => vec![
(
"Bring my skills".into(),
match self.skills_source.as_deref() {
Some(source) => format!("copy the skills in your {source} directory"),
None => "no skills found on this machine".into(),
},
),
(
"Do not sync skills".into(),
"agents run with Railway's own skills only".into(),
),
],
Step::Theme => THEMES
.iter()
.map(|theme| (theme.label.to_string(), String::new()))
.collect(),
}
}
pub fn skip_intro(&mut self) {
self.step = Step::Target;
self.cursor = 0;
}
pub fn title(&self) -> &'static str {
match self.step {
Step::Intro => "Set up cloud agents?",
Step::Target => "Where should agents live?",
Step::Agent => "Which coding agent?",
Step::Skills => "Bring your skills?",
Step::Theme => "Pick a theme",
}
}
pub fn position(&self) -> Option<(usize, usize)> {
let index = match self.step {
Step::Intro => return None,
Step::Target => 0,
Step::Agent => 1,
Step::Skills => 2,
Step::Theme => 3,
};
Some((index, 4))
}
pub fn up(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
self.preview_theme();
}
pub fn down(&mut self) {
self.cursor = (self.cursor + 1).min(self.options().len().saturating_sub(1));
self.preview_theme();
}
fn preview_theme(&mut self) {
if self.step == Step::Theme {
self.theme = self.cursor.min(THEMES.len() - 1);
}
}
pub fn previewed_theme(&self) -> &'static Theme {
&THEMES[self.theme.min(THEMES.len() - 1)]
}
pub fn select(&mut self) -> Action {
self.error = None;
match self.step {
Step::Intro => {
if self.cursor == 1 {
return Action::Cancel;
}
self.go(Step::Target);
Action::Redraw
}
Step::Target => match self.target_rows().get(self.cursor).copied() {
Some(TargetRow::Workspace(w)) => {
let workspace = &mut self.workspaces[w];
workspace.expanded = !workspace.expanded;
Action::Redraw
}
Some(TargetRow::Project(w, p)) => {
let project = &mut self.workspaces[w].projects[p];
if let [env] = project.envs.as_slice() {
self.project = Some(ProjectOption {
project_id: project.id.clone(),
project_name: project.name.clone(),
environment_id: env.id.clone(),
environment_name: env.name.clone(),
});
self.go(Step::Agent);
return Action::Redraw;
}
project.expanded = !project.expanded;
Action::Redraw
}
Some(TargetRow::Env(w, p, e)) => {
let project = &self.workspaces[w].projects[p];
let env = &project.envs[e];
self.project = Some(ProjectOption {
project_id: project.id.clone(),
project_name: project.name.clone(),
environment_id: env.id.clone(),
environment_name: env.name.clone(),
});
self.go(Step::Agent);
Action::Redraw
}
Some(TargetRow::CreateProject(w)) => {
let workspace = &self.workspaces[w];
self.busy = Some(format!("Creating Cloud Agents in {}…", workspace.name));
Action::CreateProject(workspace.id.clone())
}
Some(TargetRow::DecideLater) | None => {
self.project = None;
self.go(Step::Agent);
Action::Redraw
}
},
Step::Agent => {
self.agent = self.cursor.min(super::app::HARNESSES.len() - 1);
self.go(Step::Skills);
Action::Redraw
}
Step::Skills => {
self.skills = self.cursor == 0 && self.skills_source.is_some();
self.go(Step::Theme);
Action::Redraw
}
Step::Theme => {
self.theme = self.cursor.min(THEMES.len() - 1);
Action::Finish(Box::new(Outcome {
project: self.project.clone(),
agent: super::app::HARNESSES[self.agent].to_string(),
skills: self.skills,
skills_source: self.skills_source.clone(),
theme: THEMES[self.theme].slug.to_string(),
}))
}
}
}
pub fn back(&mut self) -> Action {
match self.step {
Step::Intro => Action::Cancel,
Step::Target => {
self.go(Step::Intro);
Action::Redraw
}
Step::Agent => {
self.go(Step::Target);
Action::Redraw
}
Step::Skills => {
self.go(Step::Agent);
Action::Redraw
}
Step::Theme => {
self.go(Step::Skills);
Action::Redraw
}
}
}
fn go(&mut self, step: Step) {
self.step = step;
self.cursor = match step {
Step::Agent => self.agent,
Step::Theme => self.theme,
_ => 0,
};
}
pub fn project_created(&mut self, result: Result<ProjectOption, String>) {
self.busy = None;
match result {
Ok(project) => {
self.project = Some(project);
self.go(Step::Agent);
}
Err(err) => self.error = Some(err),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::cloud_agent::tui::app::{EnvNode, Load, ProjectNode};
fn tree() -> Vec<WorkspaceNode> {
vec![WorkspaceNode {
id: "ws1".into(),
name: "Railway".into(),
expanded: true,
projects: vec![
ProjectNode {
id: "p1".into(),
name: "devtools".into(),
expanded: false,
envs: vec![EnvNode {
id: "e1".into(),
name: "production".into(),
expanded: false,
agents: Load::NotLoaded,
}],
},
ProjectNode {
id: "p2".into(),
name: "mono".into(),
expanded: false,
envs: vec![EnvNode {
id: "e2".into(),
name: "staging".into(),
expanded: false,
agents: Load::NotLoaded,
}],
},
],
}]
}
fn wizard() -> Wizard {
Wizard::new(&tree(), Some("codex"), Theme::default_theme(), None)
}
#[test]
fn a_multi_env_project_still_expands() {
let mut base = tree();
base[0].projects[0].envs.push(EnvNode {
id: "e9".into(),
name: "staging".into(),
expanded: false,
agents: Load::NotLoaded,
});
let mut w = Wizard::new(&base, None, Theme::default_theme(), None);
w.skip_intro();
let devtools = w
.options()
.iter()
.position(|(label, _)| label.contains("devtools"))
.unwrap();
assert!(
w.options()[devtools].0.contains('▸'),
"two environments keep the expand marker"
);
w.cursor = devtools;
assert_eq!(w.select(), Action::Redraw);
assert_eq!(w.step, Step::Target, "expanding is not picking");
assert!(
w.options()
.iter()
.any(|(label, _)| label.trim_start().starts_with("production")),
"{:?}",
w.options()
);
}
#[test]
fn declining_the_intro_leaves_without_saving() {
let mut w = wizard();
w.down();
assert_eq!(w.select(), Action::Cancel);
}
#[test]
fn the_first_workspace_opens_by_default() {
let mut w = wizard();
w.skip_intro();
let options = w.options();
assert_eq!(options[0].0, "▾ Railway");
assert!(
options
.iter()
.any(|(label, _)| label.contains("devtools (production)")),
"{options:?}"
);
assert!(
options
.iter()
.any(|(label, _)| label.contains("mono (staging)")),
"{options:?}"
);
}
#[test]
fn the_flow_collects_every_answer() {
let mut w = wizard();
assert_eq!(w.select(), Action::Redraw); assert_eq!(w.step, Step::Target);
w.down(); w.down(); w.down(); w.select();
assert_eq!(w.step, Step::Agent);
assert_eq!(w.project.as_ref().unwrap().project_name, "mono");
assert_eq!(w.project.as_ref().unwrap().environment_name, "staging");
assert_eq!(w.cursor, 2, "codex was passed in");
w.select();
assert_eq!(w.step, Step::Skills);
w.select(); assert_eq!(w.step, Step::Theme);
w.down();
let Action::Finish(outcome) = w.select() else {
panic!("expected the flow to finish");
};
assert_eq!(outcome.agent, "codex");
assert!(!outcome.skills, "nothing to bring, so nothing is promised");
assert_eq!(outcome.project.unwrap().project_id, "p2");
assert_eq!(outcome.theme, THEMES[1].slug);
}
#[test]
fn a_failed_create_keeps_the_step() {
let mut w = wizard();
w.select(); w.down(); assert_eq!(w.select(), Action::CreateProject("ws1".into()));
assert!(w.busy.is_some());
w.project_created(Err("no permission".into()));
assert_eq!(w.step, Step::Target);
assert!(w.busy.is_none());
assert_eq!(w.error.as_deref(), Some("no permission"));
w.project_created(Ok(ProjectOption {
project_id: "new".into(),
project_name: "Cloud Agents".into(),
environment_id: "env".into(),
environment_name: "production".into(),
}));
assert_eq!(w.step, Step::Agent);
assert_eq!(w.project.as_ref().unwrap().project_name, "Cloud Agents");
}
#[test]
fn escape_walks_back() {
let mut w = wizard();
w.select();
w.down();
w.select(); w.step = Step::Agent;
assert_eq!(w.back(), Action::Redraw);
assert_eq!(w.step, Step::Target);
assert_eq!(w.back(), Action::Redraw);
assert_eq!(w.step, Step::Intro);
assert_eq!(w.back(), Action::Cancel);
}
#[test]
fn the_theme_step_previews_as_you_move() {
let mut w = wizard();
w.step = Step::Theme;
w.cursor = 0;
let first = w.previewed_theme().slug;
w.down();
assert_ne!(w.previewed_theme().slug, first);
}
#[test]
fn skipping_the_intro_starts_at_the_first_question() {
let mut w = wizard();
w.skip_intro();
assert_eq!(w.step, Step::Target);
assert_eq!(w.position(), Some((0, 4)));
assert_eq!(w.back(), Action::Redraw);
assert_eq!(w.step, Step::Intro);
}
#[test]
fn with_no_workspaces_only_decide_later_is_offered() {
let mut w = Wizard::new(&[], None, Theme::default_theme(), None);
w.select(); let labels: Vec<String> = w.options().into_iter().map(|(label, _)| label).collect();
assert_eq!(labels, vec!["Decide later"]);
w.select();
assert_eq!(w.step, Step::Agent);
assert!(w.project.is_none());
}
#[test]
fn other_workspaces_stay_collapsed_until_expanded() {
let mut tree = tree();
tree.push(WorkspaceNode {
id: "ws2".into(),
name: "Personal".into(),
expanded: false,
projects: vec![ProjectNode {
id: "p3".into(),
name: "side-project".into(),
expanded: false,
envs: vec![EnvNode {
id: "e3".into(),
name: "production".into(),
expanded: false,
agents: Load::NotLoaded,
}],
}],
});
let mut w = Wizard::new(&tree, Some("codex"), Theme::default_theme(), None);
w.skip_intro();
let before = w.options();
assert!(
!before
.iter()
.any(|(label, _)| label.contains("side-project"))
);
let personal_row = before
.iter()
.position(|(label, _)| label.contains("Personal"))
.expect("the second workspace row");
w.cursor = personal_row;
w.select();
let after = w.options();
assert!(
after
.iter()
.any(|(label, _)| label.contains("side-project"))
);
assert!(
after.iter().any(|(label, _)| label.contains("devtools")),
"the first workspace is still expanded"
);
}
}