From 9afc61489bf948f2292ca14cc4279146f221dd91 Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Wed, 25 Feb 2026 20:16:19 -0500 Subject: [PATCH 1/5] Replace template selection with fuzzy-filterable menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old flow had two steps: (1) select from a short highlights list, then (2) if 'Use Template' was chosen, dump a flat list and type an ID. The user couldn't filter or navigate the full template list interactively. Now uses a single FuzzySelect showing all templates (ID + description). Type to fuzzy-filter by template ID or description, arrow keys to navigate, Enter to select. 'Clone from GitHub' and 'None (server only)' are at the bottom. Default highlights to the first highlighted template (react-ts). GitHub URL support is preserved — selecting 'Clone from GitHub' prompts for an owner/repo or git URL. --- crates/cli/src/subcommands/init.rs | 86 +++++++++++------------------- 1 file changed, 31 insertions(+), 55 deletions(-) diff --git a/crates/cli/src/subcommands/init.rs b/crates/cli/src/subcommands/init.rs index 58d567ef85d..f11300fe23d 100644 --- a/crates/cli/src/subcommands/init.rs +++ b/crates/cli/src/subcommands/init.rs @@ -5,7 +5,7 @@ use anyhow::Context; use clap::{Arg, ArgMatches}; use colored::Colorize; use convert_case::{Case, Casing}; -use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select}; +use dialoguer::{theme::ColorfulTheme, Confirm, FuzzySelect, Input, Select}; use reqwest::Url; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -771,35 +771,31 @@ async fn get_template_config_interactive( // Fully interactive mode - prompt for template/language selection let (highlights, templates) = fetch_templates_list().await?; - let mut client_choices: Vec = highlights + // Build a fuzzy-filterable list of all templates, plus special options at the bottom + let mut menu_items: Vec = templates .iter() - .map(|h| { - let template = templates.iter().find(|t| t.id == h.template_id); - match template { - Some(t) => format!("{} - {}", h.name, t.description), - None => h.name.clone(), - } - }) + .map(|t| format!("{} - {}", t.id, t.description)) .collect(); - client_choices.push("Use Template - Choose from a list of built-in template projects or clone an existing SpacetimeDB project from GitHub".to_string()); - client_choices.push("None".to_string()); + menu_items.push("Clone from GitHub (owner/repo or git URL)".to_string()); + menu_items.push("None (server only)".to_string()); - let client_selection = Select::with_theme(&theme) - .with_prompt("Select a client type for your project (you can add other clients later)") - .items(&client_choices) - .default(0) - .interact()?; + let github_index = menu_items.len() - 2; + let none_index = menu_items.len() - 1; - let other_index = highlights.len(); - let none_index = highlights.len() + 1; + // Find the default index — prefer the first highlighted template (e.g. react-ts) + let default_index = highlights + .first() + .and_then(|h| templates.iter().position(|t| t.id == h.template_id)) + .unwrap_or(0); - if client_selection < highlights.len() { - let highlight = &highlights[client_selection]; - let template = templates - .iter() - .find(|t| t.id == highlight.template_id) - .ok_or_else(|| anyhow::anyhow!("Template {} not found", highlight.template_id))?; + let selection = FuzzySelect::with_theme(&theme) + .with_prompt("Select a template (type to filter, arrows to navigate)") + .items(&menu_items) + .default(default_index) + .interact()?; + if selection < templates.len() { + let template = &templates[selection]; Ok(TemplateConfig { project_name, project_path, @@ -810,46 +806,26 @@ async fn get_template_config_interactive( template_def: Some(template.clone()), use_local: true, }) - } else if client_selection == other_index { - println!("\n{}", "Available built-in templates:".bold()); - for template in &templates { - println!(" {} - {}", template.id, template.description); - } - println!(); - + } else if selection == github_index { + // GitHub URL flow loop { - let template_id = Input::::with_theme(&theme) - .with_prompt("Template ID or GitHub repository (owner/repo) or git URL") + let repo_input = Input::::with_theme(&theme) + .with_prompt("GitHub repository (owner/repo) or git URL") .interact_text()? .trim() .to_string(); - let template_config = create_template_config_from_template_str( + if repo_input.is_empty() { + eprintln!("{}", "Please enter a GitHub repository.".bold()); + continue; + } + break create_template_config_from_template_str( project_name.clone(), project_path.clone(), - &template_id, + &repo_input, &templates, ); - // If template_id looks like a builtin template ID (e.g. kebab-case, all lowercase, no slashes, alphanumeric and dashes only) - // then ensure that it is a valid builtin template ID, if not reprompt - let is_builtin_like = |s: &str| { - !s.is_empty() - && !s.contains('/') - && s.chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') - }; - if !is_builtin_like(&template_id) { - break template_config; - } - if templates.iter().any(|t| t.id == template_id) { - break template_config; - } - eprintln!( - "{}", - "Unrecognized format. Enter a built-in ID (e.g. \"rust-chat\"), a GitHub repo (\"owner/repo\"), or a git URL." - .bold() - ); } - } else if client_selection == none_index { + } else if selection == none_index { // Ask for server language only let server_lang_choices = vec!["Rust", "C#", "TypeScript"]; let server_selection = Select::with_theme(&theme) From a89dd799f79c738fa5fea3d02d842870de35134a Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Wed, 25 Feb 2026 20:53:51 -0500 Subject: [PATCH 2/5] Start template selection at top of list --- crates/cli/src/subcommands/init.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/crates/cli/src/subcommands/init.rs b/crates/cli/src/subcommands/init.rs index f11300fe23d..4dd333a6748 100644 --- a/crates/cli/src/subcommands/init.rs +++ b/crates/cli/src/subcommands/init.rs @@ -769,7 +769,7 @@ async fn get_template_config_interactive( } // Fully interactive mode - prompt for template/language selection - let (highlights, templates) = fetch_templates_list().await?; + let (_highlights, templates) = fetch_templates_list().await?; // Build a fuzzy-filterable list of all templates, plus special options at the bottom let mut menu_items: Vec = templates @@ -782,16 +782,10 @@ async fn get_template_config_interactive( let github_index = menu_items.len() - 2; let none_index = menu_items.len() - 1; - // Find the default index — prefer the first highlighted template (e.g. react-ts) - let default_index = highlights - .first() - .and_then(|h| templates.iter().position(|t| t.id == h.template_id)) - .unwrap_or(0); - let selection = FuzzySelect::with_theme(&theme) .with_prompt("Select a template (type to filter, arrows to navigate)") .items(&menu_items) - .default(default_index) + .default(0) .interact()?; if selection < templates.len() { From 684f81cc554060984acd21d79b94c8666b9fade3 Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Thu, 26 Feb 2026 00:02:47 -0500 Subject: [PATCH 3/5] Reduce FuzzySelect flicker by limiting visible rows to 10 dialoguer's FuzzySelect clears and redraws all visible items on every keystroke. With 20+ templates the redraw area is large, causing visible flicker. Limiting to 10 visible rows (scrollable) reduces the effect. --- crates/cli/src/subcommands/init.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/cli/src/subcommands/init.rs b/crates/cli/src/subcommands/init.rs index 4dd333a6748..2888452b8c6 100644 --- a/crates/cli/src/subcommands/init.rs +++ b/crates/cli/src/subcommands/init.rs @@ -786,6 +786,7 @@ async fn get_template_config_interactive( .with_prompt("Select a template (type to filter, arrows to navigate)") .items(&menu_items) .default(0) + .max_length(10) .interact()?; if selection < templates.len() { From 479ee162bf15684382c2cd86e7cadf42ce48fd12 Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Thu, 26 Feb 2026 14:49:59 -0500 Subject: [PATCH 4/5] Restore two-menu flow: highlights first, then fuzzy template select Restores the original two-step selection: 1. First menu (Select): language/framework highlights + 'Use Template' + 'None' 2. Second menu (FuzzySelect): all templates with type-to-filter, shown only when 'Use Template' is selected This keeps the fuzzy-find UX improvement for template browsing while preserving the quick-pick highlights for common choices. --- crates/cli/src/subcommands/init.rs | 106 ++++++++++++++++++++--------- 1 file changed, 73 insertions(+), 33 deletions(-) diff --git a/crates/cli/src/subcommands/init.rs b/crates/cli/src/subcommands/init.rs index 2888452b8c6..c1c13d1f3a8 100644 --- a/crates/cli/src/subcommands/init.rs +++ b/crates/cli/src/subcommands/init.rs @@ -769,28 +769,38 @@ async fn get_template_config_interactive( } // Fully interactive mode - prompt for template/language selection - let (_highlights, templates) = fetch_templates_list().await?; + let (highlights, templates) = fetch_templates_list().await?; - // Build a fuzzy-filterable list of all templates, plus special options at the bottom - let mut menu_items: Vec = templates + // First menu: language/framework highlights + "Use Template" + "None" + let mut client_choices: Vec = highlights .iter() - .map(|t| format!("{} - {}", t.id, t.description)) + .map(|h| { + let template = templates.iter().find(|t| t.id == h.template_id); + match template { + Some(t) => format!("{} - {}", h.name, t.description), + None => h.name.clone(), + } + }) .collect(); - menu_items.push("Clone from GitHub (owner/repo or git URL)".to_string()); - menu_items.push("None (server only)".to_string()); - - let github_index = menu_items.len() - 2; - let none_index = menu_items.len() - 1; + client_choices.push("Use Template - Choose from a list of built-in template projects or clone an existing SpacetimeDB project from GitHub".to_string()); + client_choices.push("None".to_string()); - let selection = FuzzySelect::with_theme(&theme) - .with_prompt("Select a template (type to filter, arrows to navigate)") - .items(&menu_items) + let client_selection = Select::with_theme(&theme) + .with_prompt("Select a client type for your project (you can add other clients later)") + .items(&client_choices) .default(0) - .max_length(10) .interact()?; - if selection < templates.len() { - let template = &templates[selection]; + let other_index = highlights.len(); + let none_index = highlights.len() + 1; + + if client_selection < highlights.len() { + let highlight = &highlights[client_selection]; + let template = templates + .iter() + .find(|t| t.id == highlight.template_id) + .ok_or_else(|| anyhow::anyhow!("Template {} not found", highlight.template_id))?; + Ok(TemplateConfig { project_name, project_path, @@ -801,26 +811,56 @@ async fn get_template_config_interactive( template_def: Some(template.clone()), use_local: true, }) - } else if selection == github_index { - // GitHub URL flow - loop { - let repo_input = Input::::with_theme(&theme) - .with_prompt("GitHub repository (owner/repo) or git URL") - .interact_text()? - .trim() - .to_string(); - if repo_input.is_empty() { - eprintln!("{}", "Please enter a GitHub repository.".bold()); - continue; + } else if client_selection == other_index { + // Second menu: fuzzy-filterable list of all templates + GitHub clone option + let mut template_items: Vec = templates + .iter() + .map(|t| format!("{} - {}", t.id, t.description)) + .collect(); + template_items.push("Clone from GitHub (owner/repo or git URL)".to_string()); + + let github_clone_index = template_items.len() - 1; + + let selection = FuzzySelect::with_theme(&theme) + .with_prompt("Select a template (type to filter)") + .items(&template_items) + .default(0) + .interact()?; + + if selection < templates.len() { + let template = &templates[selection]; + Ok(TemplateConfig { + project_name, + project_path, + template_type: TemplateType::Builtin, + server_lang: parse_server_lang(&template.server_lang)?, + client_lang: parse_client_lang(&template.client_lang)?, + github_repo: None, + template_def: Some(template.clone()), + use_local: true, + }) + } else if selection == github_clone_index { + loop { + let repo_input = Input::::with_theme(&theme) + .with_prompt("GitHub repository (owner/repo) or git URL") + .interact_text()? + .trim() + .to_string(); + if repo_input.is_empty() { + eprintln!("{}", "Please enter a GitHub repository.".bold()); + continue; + } + break create_template_config_from_template_str( + project_name.clone(), + project_path.clone(), + &repo_input, + &templates, + ); } - break create_template_config_from_template_str( - project_name.clone(), - project_path.clone(), - &repo_input, - &templates, - ); + } else { + unreachable!("Invalid template selection index") } - } else if selection == none_index { + } else if client_selection == none_index { // Ask for server language only let server_lang_choices = vec!["Rust", "C#", "TypeScript"]; let server_selection = Select::with_theme(&theme) From 5a495eb1f81f1c6eab528a11e2da07267ec7f9d3 Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Thu, 26 Feb 2026 15:04:56 -0500 Subject: [PATCH 5/5] Use FuzzySelect for first menu too --- crates/cli/src/subcommands/init.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cli/src/subcommands/init.rs b/crates/cli/src/subcommands/init.rs index c1c13d1f3a8..77f5cb402aa 100644 --- a/crates/cli/src/subcommands/init.rs +++ b/crates/cli/src/subcommands/init.rs @@ -785,7 +785,7 @@ async fn get_template_config_interactive( client_choices.push("Use Template - Choose from a list of built-in template projects or clone an existing SpacetimeDB project from GitHub".to_string()); client_choices.push("None".to_string()); - let client_selection = Select::with_theme(&theme) + let client_selection = FuzzySelect::with_theme(&theme) .with_prompt("Select a client type for your project (you can add other clients later)") .items(&client_choices) .default(0)