From 72bccf20fad5ffacfbee8a04eb46626abd21e46a Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sat, 21 Feb 2026 10:39:42 +0800 Subject: [PATCH] feat(cli): implement a tip system for CLI commands to enhance user experience - Stateless per 5 minutes display for any tip - Use gray color for tips output Closes https://github.com/voidzero-dev/vite-plus/issues/546 --- crates/vite_global_cli/src/cli.rs | 8 +- crates/vite_global_cli/src/main.rs | 63 +++++++-- crates/vite_global_cli/src/tips/mod.rs | 121 ++++++++++++++++++ .../vite_global_cli/src/tips/short_aliases.rs | 63 +++++++++ .../src/tips/use_vpx_or_run.rs | 39 ++++++ packages/tools/src/snap-test.ts | 1 + rfcs/cli-tips.md | 107 ++++++++++++++++ 7 files changed, 386 insertions(+), 16 deletions(-) create mode 100644 crates/vite_global_cli/src/tips/mod.rs create mode 100644 crates/vite_global_cli/src/tips/short_aliases.rs create mode 100644 crates/vite_global_cli/src/tips/use_vpx_or_run.rs create mode 100644 rfcs/cli-tips.md diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index 311b9845f1..fb0a144ed4 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -1644,7 +1644,11 @@ fn apply_custom_help(cmd: clap::Command) -> clap::Command { } /// Parse CLI arguments from a custom args iterator with custom help formatting. -pub fn parse_args_from(args: impl IntoIterator) -> Args { +/// Returns `Err` with the clap error if parsing fails (e.g., unknown command). +pub fn try_parse_args_from( + args: impl IntoIterator, +) -> Result { let cmd = apply_custom_help(Args::command()); - Args::from_arg_matches(&cmd.get_matches_from(args)).expect("Failed to parse CLI arguments") + let matches = cmd.try_get_matches_from(args)?; + Args::from_arg_matches(&matches).map_err(|e| e.into()) } diff --git a/crates/vite_global_cli/src/main.rs b/crates/vite_global_cli/src/main.rs index 9de22d01bb..f335e40ed4 100644 --- a/crates/vite_global_cli/src/main.rs +++ b/crates/vite_global_cli/src/main.rs @@ -12,10 +12,14 @@ mod commands; mod error; mod js_executor; mod shim; +mod tips; use std::process::ExitCode; -use crate::cli::{parse_args_from, run_command}; +use owo_colors::OwoColorize; + +use crate::cli::run_command; +pub use crate::cli::try_parse_args_from; /// Normalize CLI arguments: /// - `vp list ...` / `vp ls ...` → `vp pm list ...` @@ -73,29 +77,60 @@ async fn main() -> ExitCode { } }; + let mut tip_context = tips::TipContext { + // Capture user args (excluding argv0) before normalization + raw_args: args[1..].to_vec(), + ..Default::default() + }; + // Normalize arguments (list/ls aliases, help rewriting) let normalized_args = normalize_args(args); // Parse CLI arguments (using custom help formatting) - let args = parse_args_from(normalized_args); + let exit_code = match try_parse_args_from(normalized_args) { + Err(e) => { + use clap::error::ErrorKind; + // Print the clap error/help/version + e.print().ok(); - match run_command(cwd, args).await { - Ok(exit_status) => { - if exit_status.success() { + // --help and --version are "errors" in clap but should exit successfully + if matches!(e.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion) { ExitCode::SUCCESS } else { - // Exit codes are typically 0-255 on Unix systems - #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] - exit_status.code().map_or(ExitCode::FAILURE, |c| ExitCode::from(c as u8)) + let code = e.exit_code(); + tip_context.clap_error = Some(e); + #[allow(clippy::cast_sign_loss)] + ExitCode::from(code as u8) } } - Err(e) => { - if matches!(&e, error::Error::UserMessage(_)) { - eprintln!("{e}"); - } else { - eprintln!("Error: {e}"); + Ok(args) => { + match run_command(cwd.clone(), args).await { + Ok(exit_status) => { + if exit_status.success() { + ExitCode::SUCCESS + } else { + // Exit codes are typically 0-255 on Unix systems + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + exit_status.code().map_or(ExitCode::FAILURE, |c| ExitCode::from(c as u8)) + } + } + Err(e) => { + if matches!(&e, error::Error::UserMessage(_)) { + eprintln!("{e}"); + } else { + eprintln!("Error: {e}"); + } + ExitCode::FAILURE + } } - ExitCode::FAILURE } + }; + + tip_context.exit_code = if exit_code == ExitCode::SUCCESS { 0 } else { 1 }; + + if let Some(tip) = tips::get_tip(&tip_context) { + eprintln!("\n{}", format!("Tip: {tip}").bright_black()); } + + exit_code } diff --git a/crates/vite_global_cli/src/tips/mod.rs b/crates/vite_global_cli/src/tips/mod.rs new file mode 100644 index 0000000000..dc7fbd4cba --- /dev/null +++ b/crates/vite_global_cli/src/tips/mod.rs @@ -0,0 +1,121 @@ +//! CLI tips system for providing helpful suggestions to users. +//! +//! Tips are shown after command execution to help users discover features +//! and shortcuts. + +mod short_aliases; +mod use_vpx_or_run; + +use clap::error::ErrorKind as ClapErrorKind; + +use self::{short_aliases::ShortAliases, use_vpx_or_run::UseVpxOrRun}; + +/// Execution context passed in from the CLI entry point. +pub struct TipContext { + /// CLI arguments as typed by the user, excluding the program name (`vp`). + pub raw_args: Vec, + /// The exit code of the command (0 = success, non-zero = failure). + pub exit_code: i32, + /// The clap error if parsing failed. + pub clap_error: Option, +} + +impl Default for TipContext { + fn default() -> Self { + TipContext { raw_args: Vec::new(), exit_code: 0, clap_error: None } + } +} + +impl TipContext { + /// Whether the command completed successfully. + #[expect(dead_code)] + pub fn success(&self) -> bool { + self.exit_code == 0 + } + + #[expect(dead_code)] + pub fn is_unknown_command_error(&self) -> bool { + if let Some(err) = &self.clap_error { + matches!(err.kind(), ClapErrorKind::InvalidSubcommand) + } else { + false + } + } + + /// Iterate positional args (skipping flags starting with `-`). + fn positionals(&self) -> impl Iterator { + self.raw_args.iter().map(String::as_str).filter(|a| !a.starts_with('-')) + } + + /// The subcommand (first positional arg, e.g., "ls", "build"). + pub fn subcommand(&self) -> Option<&str> { + self.positionals().next() + } + + /// Whether the positional args start with the given command pattern. + /// Pattern is space-separated: "pm list" matches even if flags are interspersed. + #[expect(dead_code)] + pub fn is_subcommand(&self, pattern: &str) -> bool { + let mut positionals = self.positionals(); + pattern.split_whitespace().all(|expected| positionals.next() == Some(expected)) + } +} + +/// A tip that can be shown to the user after command execution. +pub trait Tip { + /// Whether this tip is relevant given the current execution context. + fn matches(&self, ctx: &TipContext) -> bool; + /// The tip text shown to the user. + fn message(&self) -> &'static str; +} + +/// Returns all registered tips. +fn all() -> &'static [&'static dyn Tip] { + &[&ShortAliases, &UseVpxOrRun] +} + +/// Pick a random tip from those matching the current context. +/// +/// Returns `None` if: +/// - The `VITE_PLUS_CLI_TEST` env var is set (test mode) +/// - No tips match the given context +pub fn get_tip(context: &TipContext) -> Option<&'static str> { + if std::env::var_os("VITE_PLUS_CLI_TEST").is_some() { + return None; + } + + let now = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); + + let all = all(); + let matching: Vec<&&dyn Tip> = all.iter().filter(|t| t.matches(context)).collect(); + + if matching.is_empty() { + return None; + } + + // Use subsec_nanos for random tip selection + let nanos = now.subsec_nanos() as usize; + Some(matching[nanos % matching.len()].message()) +} + +/// Create a `TipContext` from a command string using real clap parsing. +/// +/// `command` is exactly what the user types in the terminal (e.g. `"vp list --flag"`). +/// The first arg is treated as the program name and excluded from `raw_args`, +/// matching how the real CLI uses `std::env::args()`. +#[cfg(test)] +pub fn tip_context_from_command(command: &str) -> TipContext { + // Split simulates what the OS does with command line args + let args: Vec = command.split_whitespace().map(String::from).collect(); + + let (exit_code, clap_error) = match crate::try_parse_args_from(args.iter().cloned()) { + Ok(_) => (0, None), + Err(e) => (e.exit_code(), Some(e)), + }; + + // raw_args excludes program name (args[0]), same as real CLI: args[1..].to_vec() + let raw_args = args.get(1..).map(<[String]>::to_vec).unwrap_or_default(); + + TipContext { raw_args, exit_code, clap_error } +} diff --git a/crates/vite_global_cli/src/tips/short_aliases.rs b/crates/vite_global_cli/src/tips/short_aliases.rs new file mode 100644 index 0000000000..00af3d2b96 --- /dev/null +++ b/crates/vite_global_cli/src/tips/short_aliases.rs @@ -0,0 +1,63 @@ +//! Tip suggesting short aliases for long-form commands. + +use super::{Tip, TipContext}; + +/// Long-form commands that have short aliases. +const LONG_FORMS: &[&str] = &["install", "remove", "uninstall", "update", "list", "link"]; + +/// Suggest short aliases when user runs a long-form command. +pub struct ShortAliases; + +impl Tip for ShortAliases { + fn matches(&self, ctx: &TipContext) -> bool { + ctx.subcommand().is_some_and(|cmd| LONG_FORMS.contains(&cmd)) + } + + fn message(&self) -> &'static str { + "Available short aliases: i = install, rm = remove, un = uninstall, up = update, ls = list, ln = link" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tips::tip_context_from_command; + + #[test] + fn matches_long_form_commands() { + for cmd in LONG_FORMS { + let ctx = tip_context_from_command(&format!("vp {cmd}")); + assert!(ShortAliases.matches(&ctx), "should match {cmd}"); + } + } + + #[test] + fn does_not_match_short_form_commands() { + let short_forms = ["i", "rm", "un", "up", "ln"]; + for cmd in short_forms { + let ctx = tip_context_from_command(&format!("vp {cmd}")); + assert!(!ShortAliases.matches(&ctx), "should not match {cmd}"); + } + } + + #[test] + fn does_not_match_other_commands() { + let other_commands = ["build", "test", "lint", "run", "pack"]; + for cmd in other_commands { + let ctx = tip_context_from_command(&format!("vp {cmd}")); + assert!(!ShortAliases.matches(&ctx), "should not match {cmd}"); + } + } + + #[test] + fn install_shows_short_alias_tip() { + let ctx = tip_context_from_command("vp install"); + assert!(ShortAliases.matches(&ctx)); + } + + #[test] + fn short_form_does_not_show_tip() { + let ctx = tip_context_from_command("vp i"); + assert!(!ShortAliases.matches(&ctx)); + } +} diff --git a/crates/vite_global_cli/src/tips/use_vpx_or_run.rs b/crates/vite_global_cli/src/tips/use_vpx_or_run.rs new file mode 100644 index 0000000000..6869ef8987 --- /dev/null +++ b/crates/vite_global_cli/src/tips/use_vpx_or_run.rs @@ -0,0 +1,39 @@ +//! Tip suggesting vpx or vp run for unknown commands. + +use super::{Tip, TipContext}; + +/// Suggest `vpx ` or `vp run