From e3ce7ec37fdb456d2cc45eb9113c7a0141c77b43 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Tue, 17 Mar 2026 14:57:40 +0800 Subject: [PATCH 1/2] feat(cli): set terminal title based on project name from package.json Instead of showing just "vp" as the terminal window title, read the project name from the nearest package.json and set it as the terminal title using the OSC 0 escape sequence. This applies to both the global CLI and the local CLI entry points. Co-Authored-By: Claude Opus 4.6 --- crates/vite_global_cli/src/main.rs | 5 +++++ crates/vite_shared/src/header.rs | 15 +++++++++++++++ crates/vite_shared/src/lib.rs | 23 +++++++++++++++++++++++ crates/vite_shared/src/package_json.rs | 3 +++ packages/cli/binding/src/cli/mod.rs | 5 +++++ 5 files changed, 51 insertions(+) diff --git a/crates/vite_global_cli/src/main.rs b/crates/vite_global_cli/src/main.rs index 0f6a709be7..da6c2a3788 100644 --- a/crates/vite_global_cli/src/main.rs +++ b/crates/vite_global_cli/src/main.rs @@ -293,6 +293,11 @@ async fn main() -> ExitCode { } }; + // Set terminal title to the project name from package.json + if let Some(project_name) = vite_shared::read_project_name(cwd.as_path().as_ref()) { + vite_shared::header::set_terminal_title(&project_name); + } + if args.len() == 1 { match command_picker::pick_top_level_command_if_interactive(&cwd) { Ok(command_picker::TopLevelCommandPick::Selected(selection)) => { diff --git a/crates/vite_shared/src/header.rs b/crates/vite_shared/src/header.rs index cf4163f0a1..7a853789f6 100644 --- a/crates/vite_shared/src/header.rs +++ b/crates/vite_shared/src/header.rs @@ -525,6 +525,21 @@ fn render_header_variant( format!("{}{}", bold(&vite_plus, prefix_bold), bold(&suffix, suffix_bold)) } +/// Set the terminal window title using OSC 0 escape sequence. +/// +/// Writes `ESC ] 0 ; BEL` to stdout when stdout is a terminal. +/// This is a no-op when stdout is not a terminal or when running in CI. +pub fn set_terminal_title(title: &str) { + use std::io::Write; + + if !std::io::stdout().is_terminal() || std::env::var_os("CI").is_some() { + return; + } + + let _ = write!(std::io::stdout(), "\x1b]0;{title}\x07"); + let _ = std::io::stdout().flush(); +} + /// Render the Vite+ CLI header string with JS-parity coloring behavior. #[must_use] pub fn vite_plus_header() -> String { diff --git a/crates/vite_shared/src/lib.rs b/crates/vite_shared/src/lib.rs index 5e742e4fb7..ed91ca966e 100644 --- a/crates/vite_shared/src/lib.rs +++ b/crates/vite_shared/src/lib.rs @@ -27,3 +27,26 @@ pub use path_env::{ }; pub use tls::ensure_tls_provider; pub use tracing::init_tracing; + +/// Read the project name from the nearest `package.json` in the given directory. +/// +/// Walks up the directory tree from `start_dir` looking for a `package.json` file +/// with a `name` field. Returns `None` if no such file is found or if it cannot +/// be parsed. +pub fn read_project_name(start_dir: &std::path::Path) -> Option<String> { + let mut dir = Some(start_dir); + while let Some(current) = dir { + let pkg_path = current.join("package.json"); + if let Ok(contents) = std::fs::read_to_string(&pkg_path) { + if let Ok(pkg) = serde_json::from_str::<PackageJson>(&contents) { + if let Some(name) = pkg.name { + if !name.is_empty() { + return Some(name.to_string()); + } + } + } + } + dir = current.parent(); + } + None +} diff --git a/crates/vite_shared/src/package_json.rs b/crates/vite_shared/src/package_json.rs index 1e1cd56647..6a24542637 100644 --- a/crates/vite_shared/src/package_json.rs +++ b/crates/vite_shared/src/package_json.rs @@ -64,6 +64,9 @@ pub struct Engines { #[derive(Deserialize, Default, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct PackageJson { + /// The package name + #[serde(default)] + pub name: Option<Str>, /// The devEngines configuration #[serde(default)] pub dev_engines: Option<DevEngines>, diff --git a/packages/cli/binding/src/cli/mod.rs b/packages/cli/binding/src/cli/mod.rs index 4cff47fdfd..d9e83c626d 100644 --- a/packages/cli/binding/src/cli/mod.rs +++ b/packages/cli/binding/src/cli/mod.rs @@ -171,6 +171,11 @@ pub async fn main( options: Option<CliOptions>, args: Option<Vec<String>>, ) -> Result<ExitStatus, Error> { + // Set terminal title to the project name from package.json + if let Some(project_name) = vite_shared::read_project_name(cwd.as_path().as_ref()) { + vite_shared::header::set_terminal_title(&project_name); + } + let args_vec: Vec<String> = args.unwrap_or_else(|| env::args().skip(1).collect()); let args_vec = normalize_help_args(args_vec); if should_print_help(&args_vec) { From 545764ceb2dcd0a63e0dddc9cad1b36d08be607b Mon Sep 17 00:00:00 2001 From: zerone0x <39543393+zerone0x@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:57:01 +0000 Subject: [PATCH 2/2] fix(cli): gate terminal title escape support --- crates/vite_shared/src/header.rs | 36 +++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/crates/vite_shared/src/header.rs b/crates/vite_shared/src/header.rs index 7a853789f6..060117be0b 100644 --- a/crates/vite_shared/src/header.rs +++ b/crates/vite_shared/src/header.rs @@ -527,12 +527,18 @@ fn render_header_variant( /// Set the terminal window title using OSC 0 escape sequence. /// -/// Writes `ESC ] 0 ; <title> BEL` to stdout when stdout is a terminal. -/// This is a no-op when stdout is not a terminal or when running in CI. +/// Writes `ESC ] 0 ; <title> BEL` only when stdout looks like a terminal with +/// ANSI/VT escape support. This is a best-effort hint: unsupported terminals +/// may ignore it, and environments without escape support are treated as no-op. pub fn set_terminal_title(title: &str) { use std::io::Write; - if !std::io::stdout().is_terminal() || std::env::var_os("CI").is_some() { + if !should_set_terminal_title() { + return; + } + + let title = sanitize_terminal_title(title); + if title.is_empty() { return; } @@ -540,6 +546,19 @@ pub fn set_terminal_title(title: &str) { let _ = std::io::stdout().flush(); } +fn should_set_terminal_title() -> bool { + let stdout = std::io::stdout(); + + stdout.is_terminal() + && std::env::var_os("CI").is_none() + && std::env::var_os("GITHUB_ACTIONS").is_none() + && on(Stream::Stdout).is_some() +} + +fn sanitize_terminal_title(title: &str) -> String { + title.chars().filter(|ch| !matches!(ch, '\u{0000}'..='\u{001f}' | '\u{007f}')).collect() +} + /// Render the Vite+ CLI header string with JS-parity coloring behavior. #[must_use] pub fn vite_plus_header() -> String { @@ -590,6 +609,17 @@ mod tests { query_terminal_colors, read_until_either, to_8bit, }; + #[test] + fn sanitize_terminal_title_strips_control_chars() { + assert_eq!(sanitize_terminal_title("my\x1b]2;bad\x07project"), "my]2;badproject"); + assert_eq!(sanitize_terminal_title("vite-plus"), "vite-plus"); + } + + #[test] + fn sanitize_terminal_title_can_return_empty() { + assert_eq!(sanitize_terminal_title("\x1b\x07\n"), ""); + } + #[test] fn to_8bit_matches_js_rules() { assert_eq!(to_8bit("ff"), Some(255));