Skip to content

Commit 2621113

Browse files
authored
feat(cli): implement a tip system for CLI commands to enhance user experience (#583)
- stateless per 5 minutes display for any tip <img width="2538" height="1162" alt="image" src="https://github.com/user-attachments/assets/6906f7d9-c010-4d10-a937-c8d1c0e61ebb" /> <img width="1508" height="552" alt="image" src="https://github.com/user-attachments/assets/77b70e5f-897d-48b6-94e3-1bbda2525999" /> Closes #546
1 parent 277648f commit 2621113

7 files changed

Lines changed: 386 additions & 16 deletions

File tree

crates/vite_global_cli/src/cli.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1644,7 +1644,11 @@ fn apply_custom_help(cmd: clap::Command) -> clap::Command {
16441644
}
16451645

16461646
/// Parse CLI arguments from a custom args iterator with custom help formatting.
1647-
pub fn parse_args_from(args: impl IntoIterator<Item = String>) -> Args {
1647+
/// Returns `Err` with the clap error if parsing fails (e.g., unknown command).
1648+
pub fn try_parse_args_from(
1649+
args: impl IntoIterator<Item = String>,
1650+
) -> Result<Args, clap::error::Error> {
16481651
let cmd = apply_custom_help(Args::command());
1649-
Args::from_arg_matches(&cmd.get_matches_from(args)).expect("Failed to parse CLI arguments")
1652+
let matches = cmd.try_get_matches_from(args)?;
1653+
Args::from_arg_matches(&matches).map_err(|e| e.into())
16501654
}

crates/vite_global_cli/src/main.rs

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,14 @@ mod commands;
1212
mod error;
1313
mod js_executor;
1414
mod shim;
15+
mod tips;
1516

1617
use std::process::ExitCode;
1718

18-
use crate::cli::{parse_args_from, run_command};
19+
use owo_colors::OwoColorize;
20+
21+
use crate::cli::run_command;
22+
pub use crate::cli::try_parse_args_from;
1923

2024
/// Normalize CLI arguments:
2125
/// - `vp list ...` / `vp ls ...` → `vp pm list ...`
@@ -73,29 +77,60 @@ async fn main() -> ExitCode {
7377
}
7478
};
7579

80+
let mut tip_context = tips::TipContext {
81+
// Capture user args (excluding argv0) before normalization
82+
raw_args: args[1..].to_vec(),
83+
..Default::default()
84+
};
85+
7686
// Normalize arguments (list/ls aliases, help rewriting)
7787
let normalized_args = normalize_args(args);
7888

7989
// Parse CLI arguments (using custom help formatting)
80-
let args = parse_args_from(normalized_args);
90+
let exit_code = match try_parse_args_from(normalized_args) {
91+
Err(e) => {
92+
use clap::error::ErrorKind;
93+
// Print the clap error/help/version
94+
e.print().ok();
8195

82-
match run_command(cwd, args).await {
83-
Ok(exit_status) => {
84-
if exit_status.success() {
96+
// --help and --version are "errors" in clap but should exit successfully
97+
if matches!(e.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion) {
8598
ExitCode::SUCCESS
8699
} else {
87-
// Exit codes are typically 0-255 on Unix systems
88-
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
89-
exit_status.code().map_or(ExitCode::FAILURE, |c| ExitCode::from(c as u8))
100+
let code = e.exit_code();
101+
tip_context.clap_error = Some(e);
102+
#[allow(clippy::cast_sign_loss)]
103+
ExitCode::from(code as u8)
90104
}
91105
}
92-
Err(e) => {
93-
if matches!(&e, error::Error::UserMessage(_)) {
94-
eprintln!("{e}");
95-
} else {
96-
eprintln!("Error: {e}");
106+
Ok(args) => {
107+
match run_command(cwd.clone(), args).await {
108+
Ok(exit_status) => {
109+
if exit_status.success() {
110+
ExitCode::SUCCESS
111+
} else {
112+
// Exit codes are typically 0-255 on Unix systems
113+
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
114+
exit_status.code().map_or(ExitCode::FAILURE, |c| ExitCode::from(c as u8))
115+
}
116+
}
117+
Err(e) => {
118+
if matches!(&e, error::Error::UserMessage(_)) {
119+
eprintln!("{e}");
120+
} else {
121+
eprintln!("Error: {e}");
122+
}
123+
ExitCode::FAILURE
124+
}
97125
}
98-
ExitCode::FAILURE
99126
}
127+
};
128+
129+
tip_context.exit_code = if exit_code == ExitCode::SUCCESS { 0 } else { 1 };
130+
131+
if let Some(tip) = tips::get_tip(&tip_context) {
132+
eprintln!("\n{}", format!("Tip: {tip}").bright_black());
100133
}
134+
135+
exit_code
101136
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
//! CLI tips system for providing helpful suggestions to users.
2+
//!
3+
//! Tips are shown after command execution to help users discover features
4+
//! and shortcuts.
5+
6+
mod short_aliases;
7+
mod use_vpx_or_run;
8+
9+
use clap::error::ErrorKind as ClapErrorKind;
10+
11+
use self::{short_aliases::ShortAliases, use_vpx_or_run::UseVpxOrRun};
12+
13+
/// Execution context passed in from the CLI entry point.
14+
pub struct TipContext {
15+
/// CLI arguments as typed by the user, excluding the program name (`vp`).
16+
pub raw_args: Vec<String>,
17+
/// The exit code of the command (0 = success, non-zero = failure).
18+
pub exit_code: i32,
19+
/// The clap error if parsing failed.
20+
pub clap_error: Option<clap::Error>,
21+
}
22+
23+
impl Default for TipContext {
24+
fn default() -> Self {
25+
TipContext { raw_args: Vec::new(), exit_code: 0, clap_error: None }
26+
}
27+
}
28+
29+
impl TipContext {
30+
/// Whether the command completed successfully.
31+
#[expect(dead_code)]
32+
pub fn success(&self) -> bool {
33+
self.exit_code == 0
34+
}
35+
36+
#[expect(dead_code)]
37+
pub fn is_unknown_command_error(&self) -> bool {
38+
if let Some(err) = &self.clap_error {
39+
matches!(err.kind(), ClapErrorKind::InvalidSubcommand)
40+
} else {
41+
false
42+
}
43+
}
44+
45+
/// Iterate positional args (skipping flags starting with `-`).
46+
fn positionals(&self) -> impl Iterator<Item = &str> {
47+
self.raw_args.iter().map(String::as_str).filter(|a| !a.starts_with('-'))
48+
}
49+
50+
/// The subcommand (first positional arg, e.g., "ls", "build").
51+
pub fn subcommand(&self) -> Option<&str> {
52+
self.positionals().next()
53+
}
54+
55+
/// Whether the positional args start with the given command pattern.
56+
/// Pattern is space-separated: "pm list" matches even if flags are interspersed.
57+
#[expect(dead_code)]
58+
pub fn is_subcommand(&self, pattern: &str) -> bool {
59+
let mut positionals = self.positionals();
60+
pattern.split_whitespace().all(|expected| positionals.next() == Some(expected))
61+
}
62+
}
63+
64+
/// A tip that can be shown to the user after command execution.
65+
pub trait Tip {
66+
/// Whether this tip is relevant given the current execution context.
67+
fn matches(&self, ctx: &TipContext) -> bool;
68+
/// The tip text shown to the user.
69+
fn message(&self) -> &'static str;
70+
}
71+
72+
/// Returns all registered tips.
73+
fn all() -> &'static [&'static dyn Tip] {
74+
&[&ShortAliases, &UseVpxOrRun]
75+
}
76+
77+
/// Pick a random tip from those matching the current context.
78+
///
79+
/// Returns `None` if:
80+
/// - The `VITE_PLUS_CLI_TEST` env var is set (test mode)
81+
/// - No tips match the given context
82+
pub fn get_tip(context: &TipContext) -> Option<&'static str> {
83+
if std::env::var_os("VITE_PLUS_CLI_TEST").is_some() {
84+
return None;
85+
}
86+
87+
let now =
88+
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
89+
90+
let all = all();
91+
let matching: Vec<&&dyn Tip> = all.iter().filter(|t| t.matches(context)).collect();
92+
93+
if matching.is_empty() {
94+
return None;
95+
}
96+
97+
// Use subsec_nanos for random tip selection
98+
let nanos = now.subsec_nanos() as usize;
99+
Some(matching[nanos % matching.len()].message())
100+
}
101+
102+
/// Create a `TipContext` from a command string using real clap parsing.
103+
///
104+
/// `command` is exactly what the user types in the terminal (e.g. `"vp list --flag"`).
105+
/// The first arg is treated as the program name and excluded from `raw_args`,
106+
/// matching how the real CLI uses `std::env::args()`.
107+
#[cfg(test)]
108+
pub fn tip_context_from_command(command: &str) -> TipContext {
109+
// Split simulates what the OS does with command line args
110+
let args: Vec<String> = command.split_whitespace().map(String::from).collect();
111+
112+
let (exit_code, clap_error) = match crate::try_parse_args_from(args.iter().cloned()) {
113+
Ok(_) => (0, None),
114+
Err(e) => (e.exit_code(), Some(e)),
115+
};
116+
117+
// raw_args excludes program name (args[0]), same as real CLI: args[1..].to_vec()
118+
let raw_args = args.get(1..).map(<[String]>::to_vec).unwrap_or_default();
119+
120+
TipContext { raw_args, exit_code, clap_error }
121+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//! Tip suggesting short aliases for long-form commands.
2+
3+
use super::{Tip, TipContext};
4+
5+
/// Long-form commands that have short aliases.
6+
const LONG_FORMS: &[&str] = &["install", "remove", "uninstall", "update", "list", "link"];
7+
8+
/// Suggest short aliases when user runs a long-form command.
9+
pub struct ShortAliases;
10+
11+
impl Tip for ShortAliases {
12+
fn matches(&self, ctx: &TipContext) -> bool {
13+
ctx.subcommand().is_some_and(|cmd| LONG_FORMS.contains(&cmd))
14+
}
15+
16+
fn message(&self) -> &'static str {
17+
"Available short aliases: i = install, rm = remove, un = uninstall, up = update, ls = list, ln = link"
18+
}
19+
}
20+
21+
#[cfg(test)]
22+
mod tests {
23+
use super::*;
24+
use crate::tips::tip_context_from_command;
25+
26+
#[test]
27+
fn matches_long_form_commands() {
28+
for cmd in LONG_FORMS {
29+
let ctx = tip_context_from_command(&format!("vp {cmd}"));
30+
assert!(ShortAliases.matches(&ctx), "should match {cmd}");
31+
}
32+
}
33+
34+
#[test]
35+
fn does_not_match_short_form_commands() {
36+
let short_forms = ["i", "rm", "un", "up", "ln"];
37+
for cmd in short_forms {
38+
let ctx = tip_context_from_command(&format!("vp {cmd}"));
39+
assert!(!ShortAliases.matches(&ctx), "should not match {cmd}");
40+
}
41+
}
42+
43+
#[test]
44+
fn does_not_match_other_commands() {
45+
let other_commands = ["build", "test", "lint", "run", "pack"];
46+
for cmd in other_commands {
47+
let ctx = tip_context_from_command(&format!("vp {cmd}"));
48+
assert!(!ShortAliases.matches(&ctx), "should not match {cmd}");
49+
}
50+
}
51+
52+
#[test]
53+
fn install_shows_short_alias_tip() {
54+
let ctx = tip_context_from_command("vp install");
55+
assert!(ShortAliases.matches(&ctx));
56+
}
57+
58+
#[test]
59+
fn short_form_does_not_show_tip() {
60+
let ctx = tip_context_from_command("vp i");
61+
assert!(!ShortAliases.matches(&ctx));
62+
}
63+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
//! Tip suggesting vpx or vp run for unknown commands.
2+
3+
use super::{Tip, TipContext};
4+
5+
/// Suggest `vpx <bin>` or `vp run <script>` when an unknown command is used.
6+
pub struct UseVpxOrRun;
7+
8+
impl Tip for UseVpxOrRun {
9+
fn matches(&self, _ctx: &TipContext) -> bool {
10+
// TODO: Enable when `vpx` is supported
11+
// ctx.is_unknown_command_error()
12+
false
13+
}
14+
15+
fn message(&self) -> &'static str {
16+
"Run a local binary with `vpx <bin>`, or a script with `vp run <script>`"
17+
}
18+
}
19+
20+
// TODO: Re-enable tests when `vpx` is supported
21+
// #[cfg(test)]
22+
// mod tests {
23+
// use super::*;
24+
// use crate::tips::tip_context_from_command;
25+
//
26+
// #[test]
27+
// fn matches_on_unknown_command() {
28+
// let ctx = tip_context_from_command("vp typecheck");
29+
// assert!(UseVpxOrRun.matches(&ctx));
30+
// assert!(ctx.is_unknown_command_error());
31+
// }
32+
//
33+
// #[test]
34+
// fn does_not_match_on_known_command() {
35+
// let ctx = tip_context_from_command("vp build");
36+
// assert!(!UseVpxOrRun.matches(&ctx));
37+
// assert!(!ctx.is_unknown_command_error());
38+
// }
39+
// }

packages/tools/src/snap-test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ async function runTestCase(name: string, tempTmpDir: string, casesDir: string) {
187187
const env: Record<string, string> = {
188188
...passThroughEnvs,
189189
// Indicate CLI is running in test mode, so that it prints more detailed outputs.
190+
// Also disables tips for stable snapshots.
190191
VITE_PLUS_CLI_TEST: '1',
191192
NO_COLOR: 'true',
192193
// set CI=true make sure snap-tests are stable on GitHub Actions

0 commit comments

Comments
 (0)