From 60ef3fb758697d62533d262af690dbc98f1458f4 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Thu, 13 Aug 2026 12:14:49 +0800 Subject: [PATCH] fix(shell): name the user, host and home in a Unix pwsh pane's title (#611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PowerShell integration read its identity from USERNAME, COMPUTERNAME and USERPROFILE. Those three spellings exist only on Windows; on macOS and Linux they come back empty, so every pwsh pane there was titled `@:` followed by a full un-abbreviated path, and the `~` shortening never fired at all. Read them through .NET instead, and take home from PowerShell's own $HOME, which is correct everywhere. Nothing gated this to Windows and nothing tested it off Windows either: every pty round-trip in this module was `#[cfg(windows)]`, so a Windows-first script shipped to two platforms it had never run on. Open the harness up to Unix and give pwsh its own round-trip there. That took two fixes to the harness. It typed at spawn time, which puts the keystrokes ahead of the cursor-position reply in the same input stream — pwsh, still waiting on that reply, eats `false\r` as the answer to its own query and the command never runs; wait for the prompt-end mark before typing. And nothing was answering `CSI 6n`, which PSReadLine blocks on before it will draw anything. Enter comes in as a parameter now, because a raw-mode reader only accepts `\r` where a line-discipline shell also takes `\n`. While rewriting the home match: require the separator. With a home of `/Users/ann` a bare StartsWith also swallowed `/Users/annex`, retitling it `~ex`. Found while investigating #583, which reports a pwsh pane freezing on `ls`. This is not that bug — the injection runs clean on macOS pwsh 7.6.4 both over a raw pty and under tmux — but it is a real defect on the same untested path. Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- .../tty7-core/src/daemon/shell_integration.rs | 216 ++++++++++++++++-- 1 file changed, 194 insertions(+), 22 deletions(-) diff --git a/crates/tty7-core/src/daemon/shell_integration.rs b/crates/tty7-core/src/daemon/shell_integration.rs index d3bd99c1..5c483d1f 100644 --- a/crates/tty7-core/src/daemon/shell_integration.rs +++ b/crates/tty7-core/src/daemon/shell_integration.rs @@ -515,6 +515,17 @@ if (-not $env:TTY7_SHELL_INTEGRATION) { # Gates the D marker so the first prompt (no command yet) emits no bogus exit. $global:__Tty7CmdActive = $false + # Who and where, for the OSC 7 host and the OSC 0 title. Read through .NET + # rather than the USERNAME / COMPUTERNAME / USERPROFILE environment variables: + # those spellings exist only on Windows, and on macOS and Linux all three come + # back empty, which left every pwsh pane there titled `@:` followed by a full + # un-abbreviated path (issue #583). $HOME is PowerShell's own automatic + # variable and is right on every platform. Resolved once — none of the three + # changes within a session. + $global:__Tty7User = [Environment]::UserName + $global:__Tty7Host = [Environment]::MachineName + $global:__Tty7Home = if ($HOME) { $HOME.Replace('\', '/').TrimEnd('/') } else { '' } + function global:prompt { # $? first: an assignment sets $? to true, so read it before anything else. $ok = $? @@ -538,21 +549,24 @@ if (-not $env:TTY7_SHELL_INTEGRATION) { # expects — while a POSIX path keeps its single slash instead of doubling it. $p = $fsPath.Replace('%', '%25').Replace('\', '/') if (-not $p.StartsWith('/')) { $p = '/' + $p } - Write-Host -NoNewline "$($global:__Tty7Esc)]7;file://$($env:COMPUTERNAME)$p$($global:__Tty7Bel)" + Write-Host -NoNewline "$($global:__Tty7Esc)]7;file://$($global:__Tty7Host)$p$($global:__Tty7Bel)" - # OSC 0 window/tab title "user@host:dir". PowerShell profiles don't set a - # title the way macOS's default zsh does, so without this every tty7 tab on - # Windows stays generic. Forward slashes (so tty7's tab-label parser can take - # the last path segment) and home shown as `~`. Re-emitted each prompt so it - # tracks cwd; a full-screen app's own title still overrides it while it runs. + # OSC 0 window/tab title "user@host:dir". Neither a PowerShell profile nor + # pwsh itself sets a useful title — on macOS pwsh emits an *empty* OSC 0 — + # so without this every tty7 tab running PowerShell stays generic, on every + # platform. Forward slashes (so tty7's tab-label parser can take the last + # path segment) and home shown as `~`. Re-emitted each prompt so it tracks + # cwd; a full-screen app's own title still overrides it while it runs. + # + # The home match needs the separator, not just the prefix: with a home of + # `/Users/ann`, a bare StartsWith also swallows `/Users/annex`, retitling it + # `~ex`. $titlePath = $fsPath.Replace('\', '/') - if ($env:USERPROFILE) { - $userHome = $env:USERPROFILE.Replace('\', '/') - if ($titlePath.StartsWith($userHome)) { - $titlePath = '~' + $titlePath.Substring($userHome.Length) - } + if ($global:__Tty7Home -and ($titlePath -eq $global:__Tty7Home -or + $titlePath.StartsWith($global:__Tty7Home + '/'))) { + $titlePath = '~' + $titlePath.Substring($global:__Tty7Home.Length) } - Write-Host -NoNewline "$($global:__Tty7Esc)]0;$($env:USERNAME)@$($env:COMPUTERNAME):$titlePath$($global:__Tty7Bel)" + Write-Host -NoNewline "$($global:__Tty7Esc)]0;$($global:__Tty7User)@$($global:__Tty7Host):$titlePath$($global:__Tty7Bel)" } # Restore the captured status so the user's own prompt sees the real result, @@ -1381,11 +1395,21 @@ mod tests { /// shell that could not find `false`) and `133;D;130` (interrupted) — a /// bootstrap that never ran the command at all would pass every assertion /// below. Matching through the terminator pins the status whole. - #[cfg(windows)] const FAILED_COMMAND_MARK: &str = "133;D;1\u{7}"; - #[cfg(windows)] - fn prompt_cycle_over_pty(program: &str, injection: &Injection) -> String { + /// Type `keys` at a freshly spawned shell and return everything it wrote back. + /// + /// `keys` carries its own Enter because the two families disagree about which + /// byte that is. A line-discipline shell gets `\n` (the pty's ICRNL would + /// accept `\r` too); PSReadLine puts the tty in raw mode and reads keys + /// itself, so only a real `\r` submits — `\n` just sits in the buffer and the + /// command never runs. + fn prompt_cycle_over_pty( + program: &str, + injection: &Injection, + keys: &[u8], + cwd: Option<&Path>, + ) -> String { use portable_pty::{CommandBuilder, PtySize, native_pty_system}; use std::io::{Read, Write}; @@ -1402,12 +1426,13 @@ mod tests { for (k, v) in &injection.env { cmd.env(k, v); } + if let Some(dir) = cwd { + cmd.cwd(dir); + } let mut child = pty.slave.spawn_command(cmd).expect("spawn shell"); let mut writer = pty.master.take_writer().expect("writer"); let mut reader = pty.master.try_clone_reader().expect("reader"); - writer.write_all(b"false\n").expect("write"); - writer.flush().expect("flush"); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { @@ -1421,27 +1446,65 @@ mod tests { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); let mut out = Vec::new(); + let mut answered = 0usize; + let mut typed = false; while std::time::Instant::now() < deadline { match rx.recv_timeout(std::time::Duration::from_millis(200)) { Ok(chunk) => out.extend_from_slice(&chunk), Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, } + let text = String::from_utf8_lossy(&out); + + // Play terminal for the one query that blocks: PSReadLine asks where + // the cursor is (`CSI 6n`) before it will draw anything, and waits for + // the answer. Nothing is behind this pty to give one, so an unanswered + // report means pwsh never echoes a keystroke and the whole test times + // out. The position itself does not matter here — only that a reply + // arrives. A line-discipline shell never asks, so this is inert for + // the Git Bash and WSL cases. + let asked = text.matches("\u{1b}[6n").count(); + if asked > answered { + for _ in answered..asked { + let _ = writer.write_all(b"\x1b[1;1R"); + } + let _ = writer.flush(); + answered = asked; + } + + // Type only once the first prompt is fully drawn (B is its last act). + // Writing at spawn time instead puts the keystrokes ahead of the + // cursor-position reply in the same input stream, and pwsh — still + // waiting on that reply — eats `false\r` as the answer to its own + // query. The command then never runs, and the failure reads as a + // missing C mark rather than as the race it is. + if !typed && text.contains("133;B") { + writer.write_all(keys).expect("write"); + writer.flush().expect("flush"); + typed = true; + } + // Stop only once the typed command's own D report lands. A shell can // emit an unpaired D while drawing its first prompt, and breaking on // that would sample the transcript before the prompt cycle under test // has run at all. - if String::from_utf8_lossy(&out).contains(FAILED_COMMAND_MARK) { + if typed && text.contains(FAILED_COMMAND_MARK) { break; } } + // Reap before asserting: a panic here would otherwise leave the shell + // holding the pty open for the rest of the test run. let _ = child.kill(); let _ = child.wait(); drop(pty.master); + assert!( + typed, + "no prompt-end mark ever arrived, so nothing was typed; got:\n{}", + String::from_utf8_lossy(&out) + ); String::from_utf8_lossy(&out).into_owned() } - #[cfg(windows)] fn reported_cwd(text: &str) -> PathBuf { let payload = text .split("\u{1b}]") @@ -1461,7 +1524,7 @@ mod tests { }; let bash = bash.to_string_lossy().into_owned(); let injection = setup(Some(&bash), &[], false).expect("bash integration"); - let text = prompt_cycle_over_pty(&bash, &injection); + let text = prompt_cycle_over_pty(&bash, &injection, b"false\n", None); for mark in ["133;A", "133;B", "133;C", FAILED_COMMAND_MARK] { assert!( @@ -1491,7 +1554,7 @@ mod tests { "~".into(), ]; let injection = setup(Some("wsl.exe"), &args, false).expect("wsl integration"); - let text = prompt_cycle_over_pty("wsl.exe", &injection); + let text = prompt_cycle_over_pty("wsl.exe", &injection, b"false\n", None); for mark in ["133;A", "133;B", "133;C", FAILED_COMMAND_MARK] { assert!( @@ -1506,6 +1569,85 @@ mod tests { ); } + /// The OSC 0 title the shell settled on, as `user@host:dir`. + fn reported_title(text: &str) -> String { + text.split("\u{1b}]") + .filter_map(|s| s.strip_prefix("0;")) + .filter_map(|s| s.split(['\u{7}', '\u{1b}']).next()) + // pwsh emits an empty OSC 0 of its own just before ours; the one + // under test is whatever is left after dropping those. + .filter(|t| !t.is_empty()) + .last() + .unwrap_or_else(|| panic!("expected a non-empty OSC 0 title; got:\n{text}")) + .to_string() + } + + #[cfg(unix)] + fn pwsh_on_path() -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|dir| dir.join("pwsh")) + .find(|p| p.is_file()) + .map(|p| p.to_string_lossy().into_owned()) + } + + /// Guards the Unix half of the PowerShell integration, which had no coverage + /// at all until #583 — every pty round-trip here used to be `#[cfg(windows)]`, + /// so a script written against `$env:USERNAME` / `$env:COMPUTERNAME` / + /// `$env:USERPROFILE` shipped for two platforms where all three are empty. + #[cfg(unix)] + #[test] + fn pwsh_reports_the_full_prompt_cycle_over_a_real_pty_on_unix() { + let Some(pwsh) = pwsh_on_path() else { + eprintln!("skipping: pwsh not installed"); + return; + }; + let injection = setup(Some(&pwsh), &[], false).expect("powershell integration"); + // From $HOME, so the title's `~` abbreviation is exercised rather than + // assumed — it is the half that silently did nothing on Unix. + let home = PathBuf::from(std::env::var("HOME").expect("HOME")); + let text = prompt_cycle_over_pty(&pwsh, &injection, b"false\r", Some(&home)); + + for mark in ["133;A", "133;B", "133;C", FAILED_COMMAND_MARK] { + assert!( + text.contains(mark), + "pwsh must report {mark:?}; got:\n{text}" + ); + } + + let cwd = reported_cwd(&text); + assert_eq!( + cwd, + home.canonicalize().unwrap_or(home.clone()), + "OSC 7 must name the pane's real cwd" + ); + + // Asserted by shape, not against $USER: a CI runner does not reliably + // export it, and the bug being guarded is emptiness — `@:` — not which + // name lands there. When the environment does name the user, pin it. + let title = reported_title(&text); + let (who, path) = title.split_once(':').expect("title is `user@host:dir`"); + let (user, host) = who.split_once('@').expect("title is `user@host:dir`"); + assert!( + !user.is_empty(), + "the title must name the user — `$env:USERNAME` is empty on Unix, \ + which is what left it starting with a bare `@`; got {title:?}" + ); + assert!( + !host.is_empty(), + "the title must name the host — `$env:COMPUTERNAME` is empty on Unix; \ + got {title:?}" + ); + if let Ok(expected) = std::env::var("USER") { + assert_eq!(user, expected, "the title named the wrong user"); + } + assert_eq!( + path, "~", + "$HOME must abbreviate to `~` — the old check keyed off \ + `$env:USERPROFILE`, which does not exist on Unix; got {title:?}" + ); + } + #[test] fn shell_kind_maps_known_basenames() { assert!(matches!(shell_kind(Some("/bin/zsh")), Some(ShellKind::Zsh))); @@ -2236,11 +2378,41 @@ mod tests { #[test] fn powershell_integration_sets_an_osc_title() { let s = POWERSHELL_INTEGRATION; - assert!(s.contains("]0;$($env:USERNAME)@$($env:COMPUTERNAME):")); + assert!(s.contains("]0;$($global:__Tty7User)@$($global:__Tty7Host):")); assert!(s.contains("$titlePath = '~'")); assert!(s.contains("$titlePath = $fsPath.Replace('\\', '/')")); } + /// `$env:USERNAME`, `$env:COMPUTERNAME` and `$env:USERPROFILE` are Windows-only + /// spellings that are simply empty on macOS and Linux — reaching for any of + /// them is what titled every Unix pwsh pane `@:` plus a raw absolute path + /// (#583). The script runs unchanged on all three platforms, so the cheapest + /// guard is to keep those names out of it entirely. + #[test] + fn powershell_integration_reads_identity_platform_neutrally() { + let s = POWERSHELL_INTEGRATION; + for windows_only in ["$env:USERNAME", "$env:COMPUTERNAME", "$env:USERPROFILE"] { + assert!( + !s.contains(windows_only), + "{windows_only} is empty on macOS and Linux; use the .NET \ + equivalent so the one script is right on every platform" + ); + } + assert!(s.contains("[Environment]::UserName")); + assert!(s.contains("[Environment]::MachineName")); + assert!(s.contains("$global:__Tty7Home = if ($HOME)")); + } + + /// A bare `StartsWith($home)` also matches the sibling directory whose name + /// merely begins with the home directory's, so `/Users/annex` used to be + /// retitled `~ex`. The separator has to be part of the match. + #[test] + fn powershell_home_abbreviation_requires_a_path_separator() { + let s = POWERSHELL_INTEGRATION; + assert!(s.contains("$titlePath.StartsWith($global:__Tty7Home + '/')")); + assert!(s.contains("$titlePath -eq $global:__Tty7Home")); + } + #[test] fn base64_encode_matches_rfc4648_vectors() { assert_eq!(base64_encode(b""), "");