diff --git a/crates/vite_global_cli/src/main.rs b/crates/vite_global_cli/src/main.rs
index 9f8896d859..330d9a9681 100644
--- a/crates/vite_global_cli/src/main.rs
+++ b/crates/vite_global_cli/src/main.rs
@@ -247,6 +247,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 c6bf7c70e6..fcf503ed8f 100644
--- a/crates/vite_shared/src/header.rs
+++ b/crates/vite_shared/src/header.rs
@@ -525,6 +525,40 @@ 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` 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 !should_set_terminal_title() {
+ return;
+ }
+
+ let title = sanitize_terminal_title(title);
+ if title.is_empty() {
+ return;
+ }
+
+ let _ = write!(std::io::stdout(), "\x1b]0;{title}\x07");
+ 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 {
@@ -545,6 +579,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));
diff --git a/crates/vite_shared/src/lib.rs b/crates/vite_shared/src/lib.rs
index d31f884e58..35de741c49 100644
--- a/crates/vite_shared/src/lib.rs
+++ b/crates/vite_shared/src/lib.rs
@@ -18,3 +18,26 @@ pub use path_env::{
prepend_to_path_env,
};
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 {
+ 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::(&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,
/// The devEngines configuration
#[serde(default)]
pub dev_engines: Option,
diff --git a/packages/cli/binding/src/cli.rs b/packages/cli/binding/src/cli.rs
index d33c960f9c..d47dc3f3c4 100644
--- a/packages/cli/binding/src/cli.rs
+++ b/packages/cli/binding/src/cli.rs
@@ -1311,6 +1311,11 @@ pub async fn main(
options: Option,
args: Option>,
) -> Result {
+ // 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 = args.unwrap_or_else(|| env::args().skip(1).collect());
let args_vec = normalize_help_args(args_vec);
if should_print_help(&args_vec) {