Merge pull request #134 from l0ng-ai/feat/wsl-shell-integration

feat(shell-integration): support WSL
This commit is contained in:
l0ng-ai
2026-07-19 20:48:47 +08:00
committed by GitHub
5 changed files with 606 additions and 105 deletions
+8
View File
@@ -308,6 +308,14 @@ pub fn git_bash_path() -> Option<PathBuf> {
find_git_bash()
}
/// Installed WSL distributions. Exposed only to tests, for the same reason as
/// [`git_bash_path`]: the live-PTY check needs a real distro to launch into,
/// and skips itself when there is none.
#[cfg(all(windows, test))]
pub fn wsl_distros() -> Vec<String> {
list_wsl_distros()
}
/// Git Bash from the usual Git-for-Windows install roots (machine-wide x64,
/// x86, and the per-user installer's home).
#[cfg(windows)]
+146 -18
View File
@@ -163,7 +163,7 @@ fn apply_shell_integration(
// sentinel builder. Integrations that need argv (fish `-C`, bash `--rcfile`,
// PowerShell flags) must use an explicit command builder first. Env-only zsh
// integration keeps the default login-shell path.
if integration.force_non_login || (cmd.is_default_prog() && !integration.args.is_empty()) {
if integration.replaces_argv || (cmd.is_default_prog() && !integration.args.is_empty()) {
*cmd = CommandBuilder::new(resolved_program);
}
cmd.args(&integration.args);
@@ -184,22 +184,66 @@ fn build_spawn_config(
shell: Option<ShellSpec>,
) -> anyhow::Result<SpawnConfig> {
let initial_cwd = initial_working_directory(cwd);
let (cmd, integration_dir) = build_shell_command(shell, &initial_cwd)?;
// Resolved here rather than inside `build_shell_command` because the WSL tag
// must be read off the shell we *actually* launch. `shell` is only the
// per-spawn override; `config.json` supplies the program when it is `None`,
// and a `wsl.exe` configured there is just as much a WSL pane as one picked
// from the dropdown.
let configured = choose_shell(shell, crate::core::config::shell_command());
let remote = wsl_remote_context(configured.as_ref());
let (cmd, integration_dir) = build_shell_command(configured, &initial_cwd)?;
Ok(SpawnConfig {
cmd,
initial_cwd,
integration_dir,
remote: None,
remote,
})
}
/// Tag a `wsl.exe` pane as living in another filesystem namespace, from the
/// resolved shell rather than the process table — `wsl.exe` is exactly what tty7
/// launched, so there is nothing to detect.
///
/// This is what makes `TerminalView::local_cwd` decline the distro's cwd, and
/// so what keeps the local git probe, path completion, link resolution and cwd
/// inheritance away from a path that means nothing on this side (and that
/// Windows would read as drive-relative). It is set whether or not shell
/// integration succeeded: an unintegrated WSL pane reports no cwd today, but if
/// it ever does the gate must already be in place.
///
/// Takes the post-[`choose_shell`] program, not the per-spawn override: a
/// `wsl.exe` written into `config.json` reaches the same integration and so must
/// reach the same tag.
fn wsl_remote_context(shell: Option<&ChosenShell>) -> Option<RemoteContext> {
if !cfg!(windows) {
return None;
}
let chosen = shell?;
let base = std::path::Path::new(&chosen.program)
.file_name()?
.to_str()?
.to_ascii_lowercase();
if base.strip_suffix(".exe").unwrap_or(&base) != "wsl" {
return None;
}
Some(RemoteContext {
kind: RemoteKind::Wsl,
argv: Vec::new(),
// The distro, when the args name one; otherwise `wsl.exe` picks the
// default and we have no name for it without another probe. Shared with
// the integration so the two can't disagree about which distro an argv
// names — they are handed the very same args.
target: shell_integration::wsl_distro(&chosen.args).unwrap_or_default(),
})
}
/// Build the argv for a spawn from an already-resolved shell (see
/// [`choose_shell`]); `None` means the platform default (the login shell on
/// Unix, PowerShell on Windows).
fn build_shell_command(
shell: Option<ShellSpec>,
configured: Option<ChosenShell>,
initial_cwd: &Option<PathBuf>,
) -> anyhow::Result<(CommandBuilder, Option<PathBuf>)> {
// Build the shell command; `None` means the platform default (the login
// shell on Unix, PowerShell on Windows).
let configured = choose_shell(shell, crate::core::config::shell_command());
let mut cmd = match &configured {
Some(chosen) => {
let mut c = CommandBuilder::new(&chosen.program);
@@ -218,12 +262,14 @@ fn build_shell_command(
None => default_shell_name(&cmd),
};
// Shell integration: inject OSC 7 / OSC 133 hooks (zsh/fish/bash/PowerShell
// — see `daemon::shell_integration`). Best effort — `None` (an unsupported
// shell, or a bash/PowerShell with unpreservable custom args) means we launch
// bare.
// Shell integration: inject OSC 7 / OSC 133 hooks (zsh/fish/bash/PowerShell,
// and through `wsl.exe` into a distro — see `daemon::shell_integration`).
// Best effort — `None` (an unsupported shell, or one with unpreservable
// custom args) means we launch bare. The args go in because the WSL path
// reads the distro out of them.
let integration = shell_integration::setup(
Some(&resolved_program),
configured.as_ref().map_or(&[][..], |c| c.args.as_slice()),
has_custom_args(configured.as_ref()),
);
if let Some(integration) = &integration {
@@ -938,15 +984,23 @@ impl DaemonPane {
std::time::Instant::now() + REMOTE_CONTEXT_POLL_INTERVAL;
}
let remote = if poll_now {
// A native-SSH pane already carries its own remote
// context; process-table detection must not clobber
// it (this pane *is* SSH). Only a plain PTY pane gets
// foreground `ssh` detection.
// A pane tty7 itself spawned as remote (native SSH,
// or WSL) already carries its own context from the
// spawn spec; process-table detection must not
// clobber it. Only `Ssh` — the kind this very probe
// produces — may be replaced, so a pane that has
// since left a foreground `ssh` clears correctly.
//
// Testing `!= Ssh` rather than `== NativeSsh` is
// load-bearing for WSL: `wsl.exe` is not `ssh`, so
// the probe returns `None` and would blank the
// context on the very next poll — twice a second,
// each time also clearing the pane's cwd.
let managed = {
let st = state.lock().unwrap();
st.remote
.as_ref()
.is_some_and(|remote| remote.kind == RemoteKind::NativeSsh)
.is_some_and(|remote| remote.kind != RemoteKind::Ssh)
};
(!managed).then(&foreground_remote)
} else {
@@ -2316,13 +2370,87 @@ mod tests {
assert!(!has_custom_args(None));
}
/// A WSL pane must be tagged as living in another filesystem namespace, so
/// `TerminalView::local_cwd` declines the distro's cwd and the local git
/// probe / completion / link resolution / cwd inheritance never see a path
/// that means nothing here — and that Windows would read as drive-relative
/// (`/home/me` -> `C:\home\me`) rather than reject.
#[cfg(windows)]
#[test]
fn wsl_panes_are_tagged_as_a_foreign_filesystem() {
let spec = |program: &str, args: Vec<&str>| ChosenShell {
program: program.to_string(),
args: args.into_iter().map(str::to_string).collect(),
args_are_tty7_defaults: true,
};
// The dropdown's WSL row.
let ctx = wsl_remote_context(Some(&spec(
"wsl.exe",
vec!["--distribution", "Ubuntu-24.04", "--cd", "~"],
)))
.expect("wsl.exe must be tagged");
assert_eq!(ctx.kind, RemoteKind::Wsl);
// The distro rides along as the target so the UI has a name for it.
assert_eq!(ctx.target, "Ubuntu-24.04");
// Nothing reads `argv` for this kind; it is not an ssh invocation.
assert!(ctx.argv.is_empty());
// Short flag, and no flag at all (wsl.exe then picks the default
// distro — still a WSL pane, just one we have no name for).
assert_eq!(
wsl_remote_context(Some(&spec("wsl.exe", vec!["-d", "Debian"])))
.expect("short flag")
.target,
"Debian"
);
assert_eq!(
wsl_remote_context(Some(&spec("wsl.exe", vec![])))
.expect("default distro is still WSL")
.target,
""
);
// The `--distribution=NAME` spelling too — the tag reads the distro with
// the integration's own parser, so the two cannot disagree about an argv
// they are both handed.
assert_eq!(
wsl_remote_context(Some(&spec("wsl.exe", vec!["--distribution=Arch"])))
.expect("joined flag")
.target,
"Arch"
);
// Case- and suffix-insensitive, like every other Windows program name.
assert!(wsl_remote_context(Some(&spec(r"C:\Windows\System32\WSL.EXE", vec![]))).is_some());
// Regression: the tag is read off the *resolved* shell, not the
// per-spawn override. A `wsl.exe` written into `config.json` reaches
// `setup_wsl` with no override in play (empty args, so nothing custom to
// preserve), so the distro starts reporting its own cwd — and an
// untagged pane would hand `/home/me/proj` straight to the local git
// probe, which Windows resolves drive-relative to `C:\home\me\proj`.
let from_config = choose_shell(None, Some(("wsl.exe".to_string(), Vec::new())));
assert_eq!(
wsl_remote_context(from_config.as_ref()).map(|c| c.kind),
Some(RemoteKind::Wsl),
"a configured wsl.exe is as much a WSL pane as a dropdown one"
);
// Everything else is a local pane and must not be tagged — tagging it
// would silently disable its git status, completion and cwd inheritance.
assert!(wsl_remote_context(Some(&spec("powershell.exe", vec![]))).is_none());
assert!(
wsl_remote_context(Some(&spec(r"C:\Program Files\Git\bin\bash.exe", vec![]))).is_none()
);
assert!(wsl_remote_context(None).is_none());
}
#[test]
fn arg_based_integration_rebuilds_default_shell_builder() {
let mut cmd = CommandBuilder::new_default_prog();
let injection = shell_integration::Injection {
env: std::collections::HashMap::new(),
args: vec!["-C".to_string(), "echo ready".to_string()],
force_non_login: false,
replaces_argv: false,
dir: None,
};
@@ -2345,7 +2473,7 @@ mod tests {
let injection = shell_integration::Injection {
env,
args: Vec::new(),
force_non_login: false,
replaces_argv: false,
dir: None,
};
+26 -3
View File
@@ -132,14 +132,21 @@ pub struct PaneInfo {
pub alive: bool,
}
/// A foreground remote session the daemon can prove from the local process table.
/// A pane whose filesystem is not the host's — either a remote session, or a
/// local one behind a boundary the host's own tools can't follow (WSL).
///
/// The common consequence, whatever the kind, is that the pane's cwd names a
/// path in *that* namespace: see `TerminalView::local_cwd`, which is what keeps
/// a local `git` / `read_dir` / spawn away from it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RemoteContext {
pub kind: RemoteKind,
/// Original foreground argv. Kept so follow-up operations can preserve ssh
/// config flags such as `-F`, `-p`, and `-J` rather than guessing.
/// config flags such as `-F`, `-p`, and `-J` rather than guessing. Empty
/// for kinds that aren't detected from a foreground process.
pub argv: Vec<String>,
/// The destination token (`host`, `user@host`, or ssh config alias).
/// The destination token: `host`, `user@host`, or ssh config alias for the
/// ssh kinds; the distro name for [`RemoteKind::Wsl`].
pub target: String,
}
@@ -154,6 +161,15 @@ pub enum RemoteKind {
/// (`daemon::ssh`). Forwarding / SFTP reach the connection through the
/// in-memory registry.
NativeSsh,
/// A `wsl.exe` pane: not remote in the network sense, but its shell lives
/// inside a distro with its own filesystem namespace, so a cwd it reports
/// (`/home/me/proj`) means nothing to the Windows-side host — and on
/// Windows is *drive-relative* rather than invalid, so it silently resolves
/// to `C:\home\me\proj`. Set at spawn time from the `ShellSpec`, not
/// detected from the process table. Nothing SSH-specific applies to it:
/// callers that mean "an SSH pane" must test the kind, not merely that a
/// `RemoteContext` is present.
Wsl,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -1544,6 +1560,13 @@ mod tests {
argv: vec!["ssh".into(), "-p".into(), "2222".into(), "dev".into()],
target: "dev".into(),
})),
// A WSL pane's context rides the same wire; `kind` is serialized
// kebab-case, so this pins the encoding of the new variant.
DaemonMsg::RemoteContext(Some(RemoteContext {
kind: RemoteKind::Wsl,
argv: Vec::new(),
target: "Ubuntu-24.04".into(),
})),
DaemonMsg::RemoteContext(None),
DaemonMsg::Agent(Some(crate::core::cli_agent::CLIAgent::Claude)),
DaemonMsg::Agent(Some(crate::core::cli_agent::CLIAgent::Codex)),
+411 -81
View File
@@ -30,13 +30,18 @@
//! plain non-login shell instead and have our rcfile manually replay the
//! login-shell startup-file chain (`/etc/profile`, `~/.bash_profile` &
//! co.) before layering hooks on top — see [`setup_bash`] and
//! [`Injection::force_non_login`]. Bash also has no native precmd/preexec,
//! [`Injection::replaces_argv`]. Bash also has no native precmd/preexec,
//! so the hook body vendors the relevant parts of
//! [bash-preexec](https://github.com/rcaloras/bash-preexec) (MIT), the
//! same shim VS Code relies on for this. This path covers Git Bash too —
//! the msys2 bash Git for Windows ships is spawned as `bash.exe` by
//! absolute path, and needs only its rcfile path spelled with forward
//! slashes (see [`bash_path`]).
//! - **WSL** is not a shell but a launcher: `wsl.exe` starts a shell *inside*
//! a distro, so the integration has to reach through it. We probe the
//! distro's login shell, write the matching rcfile on the Windows side, and
//! pass its path in via `WSLENV`, which translates it to the distro's view
//! of the filesystem. See [`setup_wsl`]. Only bash is wired up so far.
//! - **PowerShell** (the Windows default, and any `pwsh`) has no dotfile
//! redirect either, but `-EncodedCommand` runs a script *after* its own
//! profiles load — like fish's `-C`, no file on disk. It has no
@@ -45,19 +50,22 @@
//! `PSConsoleHostReadLine`, PSReadLine's line reader (the closest thing to
//! a preexec, for the C mark). See [`setup_powershell`].
//!
//! Across all four: **the user's own dotfiles are never modified** — the
//! Across all of them: **the user's own dotfiles are never modified** — the
//! mechanisms above only affect shells tty7 itself launches.
//!
//! The two remaining Windows dropdown entries stay unintegrated by design, not
//! omission. **cmd** exposes exactly one hook, the `PROMPT` env var, which can
//! emit the `A`/`B` marks but not `C` or `D`: it has no preexec/postexec, and
//! `PROMPT` is expanded when it is *set*, so even `%ERRORLEVEL%` is out of
//! reach. Since only `C` clears `at_prompt` (see `pane::handle_osc133`), an
//! A/B-only shell would leave the line editor owning the keyboard for the
//! whole of every command — worse than no integration. **WSL** is spawned as
//! `wsl.exe`, the Windows-side launcher; reaching the distro's own shell would
//! mean detecting which shell that is per distro and routing the injection
//! through `WSLENV` path translation, which is its own piece of work.
//! **cmd** stays unintegrated by design, not omission. It exposes exactly one
//! hook, the `PROMPT` env var, which can emit the `A`/`B` marks but not `C` or
//! `D`: it has no preexec/postexec, and `PROMPT` is expanded when it is *set*,
//! so even `%ERRORLEVEL%` is out of reach. Since only `C` clears `at_prompt`
//! (see `pane::handle_osc133`), an A/B-only shell would leave the line editor
//! owning the keyboard for the whole of every command — worse than no
//! integration at all.
//!
//! The install-guard sentinel (`TTY7_SHELL_INTEGRATION`, see [`setup`]) does
//! not cross into WSL, and deliberately isn't listed in `WSLENV`: only vars
//! named there cross, so a distro shell always starts with it unset — which is
//! correct, since it *is* a fresh top-level interactive shell. Its own
//! descendants inside the distro then see the `1` it exports, as on any Linux.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
@@ -709,13 +717,20 @@ pub struct Injection {
pub env: HashMap<String, String>,
/// Extra argv entries to append after the program (e.g. bash's
/// `--rcfile <path>`, fish's `-C <script>`). Empty for zsh, which needs no
/// spawn-time changes at all.
/// spawn-time changes at all. When [`replaces_argv`](Self::replaces_argv)
/// is set these are the *whole* argv, not an addition to it.
pub args: Vec<String>,
/// If set, the caller must spawn the shell as a plain (non-login) process
/// rather than however it normally would — only bash needs this (see the
/// module docs), and only when the caller can freely choose the spawn
/// invocation (i.e. no user-configured custom shell args to preserve).
pub force_non_login: bool,
/// If set, [`args`](Self::args) replace the argv the caller would otherwise
/// have used, rather than extending it. Only offered when the caller can
/// freely choose the spawn invocation (i.e. no user-configured custom shell
/// args to preserve). Two integrations need it, for different reasons:
///
/// - **bash**, because `--rcfile` is ignored for a *login* shell, so the
/// caller's login invocation has to become a plain one (the rcfile
/// replays the login chain itself — see the module docs).
/// - **WSL**, because the launch flags and the command must be reordered
/// around a `--` separator, which appending cannot express.
pub replaces_argv: bool,
/// The throwaway dir we created, if any; the terminal owns it and removes
/// it on drop so it doesn't accumulate across sessions. `None` for fish,
/// which needs no files on disk at all.
@@ -763,6 +778,10 @@ enum ShellKind {
Bash,
Fish,
PowerShell,
/// `wsl.exe`, the Windows-side launcher. Not a shell itself — the
/// integration has to reach *through* it to the distro's own shell. See
/// [`setup_wsl`].
Wsl,
}
fn shell_kind(program: Option<&str>) -> Option<ShellKind> {
@@ -786,10 +805,59 @@ fn shell_kind(program: Option<&str>) -> Option<ShellKind> {
"bash" => Some(ShellKind::Bash),
"fish" => Some(ShellKind::Fish),
"powershell" | "pwsh" => Some(ShellKind::PowerShell),
"wsl" => Some(ShellKind::Wsl),
_ => None,
}
}
/// The distro named by a `wsl.exe` argv, if any. tty7's own launch args spell it
/// `--distribution <name>` (`core::shells::detect_shells`); `-d` is the short
/// form a user-configured shell may use. Absent means "the default distro",
/// which is also what `wsl.exe` does with no flag — so `None` is a valid answer,
/// not a failure.
///
/// Shared with `pane::wsl_remote_context`, which names the same distro in the
/// pane's [`RemoteContext`](crate::daemon::protocol::RemoteContext) from the
/// same argv: two parsers for one flag would be free to disagree.
pub(crate) fn wsl_distro(args: &[String]) -> Option<String> {
let mut it = args.iter();
while let Some(a) = it.next() {
if a == "--distribution" || a == "-d" {
return it.next().cloned();
}
if let Some(v) = a.strip_prefix("--distribution=") {
return Some(v.to_string());
}
}
None
}
/// Add our entries to a `WSLENV` value, preserving whatever was already there.
///
/// `WSLENV` is a colon-separated list of variable names, each optionally
/// suffixed with flags — `/p` meaning "translate this value as a path when it
/// crosses the boundary", which is how the rcfile's Windows path becomes a
/// `/mnt/c/...` one the distro can read. Overwriting it wholesale would silently
/// drop the user's own entries, so append and de-duplicate by name.
#[cfg_attr(not(windows), allow(dead_code))]
fn wslenv_with(existing: Option<&str>, additions: &[&str]) -> String {
let mut out: Vec<String> = existing
.unwrap_or("")
.split(':')
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
for add in additions {
let name = add.split('/').next().unwrap_or(add);
// A name already present wins whatever flags the user gave it; ours is
// additive, not a correction of their configuration.
if !out.iter().any(|e| e.split('/').next().unwrap_or(e) == name) {
out.push((*add).to_string());
}
}
out.join(":")
}
/// Whether a Windows `bash` program path is the msys bash that Git for Windows
/// (or msys2) ships, as opposed to `C:\Windows\System32\bash.exe` — the WSL
/// launcher, which exists on any machine with WSL and normally sits ahead of
@@ -860,7 +928,7 @@ fn setup_zsh() -> Option<Injection> {
Some(Injection {
env,
args: Vec::new(),
force_non_login: false,
replaces_argv: false,
dir: Some(dir),
})
}
@@ -872,7 +940,7 @@ fn setup_fish() -> Option<Injection> {
Some(Injection {
env: HashMap::new(),
args: vec!["-C".to_string(), FISH_INTEGRATION.to_string()],
force_non_login: false,
replaces_argv: false,
dir: None,
})
}
@@ -894,7 +962,7 @@ fn setup_powershell() -> Option<Injection> {
"-EncodedCommand".to_string(),
powershell_encoded_command(POWERSHELL_INTEGRATION),
],
force_non_login: false,
replaces_argv: false,
dir: None,
})
}
@@ -988,11 +1056,123 @@ fn setup_bash() -> Option<Injection> {
// macOS's shipped `/bin/bash` — refuses to parse a long option once a
// short one has been seen.
args: vec!["--rcfile".to_string(), bash_path(&rcfile), "-i".to_string()],
force_non_login: true,
replaces_argv: true,
dir: Some(dir),
})
}
/// Env var carrying the rcfile path across the Windows/WSL boundary. Listed in
/// `WSLENV` with the `/p` flag so WSL rewrites it to the distro's view of the
/// path (`C:\Users\…` -> `/mnt/c/Users/…`), which is why we don't hardcode the
/// `/mnt` automount root ourselves — it is configurable in `/etc/wsl.conf`.
const WSL_RCFILE_ENV: &str = "TTY7_RC";
/// Pick the distro's shell and exec it, *inside the distro*.
///
/// Deliberately not a Windows-side probe. Spawning `wsl.exe` to ask which shell
/// a distro uses blocks the whole spawn path: the client waits synchronously for
/// the daemon's `Spawn` reply (see `terminal::remote::spawn`), so on a cold WSL
/// start — seconds, while the distro boots — the entire window freezes. Folding
/// the decision into the one `wsl.exe` invocation we were always going to make
/// costs nothing and cannot block, because there is no second invocation.
///
/// `$SHELL` rather than `getent passwd`: WSL populates it from the user's passwd
/// entry, so inside the distro it already *is* the login shell of record — the
/// same source `shell_kind` trusts on Unix. Written without a variable
/// assignment so the whole thing stays one `case`, which keeps it robust to the
/// layers of quoting between here and `sh`.
const WSL_EXEC_SCRIPT: &str = concat!(
r#"case "${SHELL:-}" in "#,
r#"*/bash) exec "$SHELL" --rcfile "$TTY7_RC" -i ;; "#,
r#"*) exec "${SHELL:-/bin/sh}" -l ;; "#,
"esac"
);
/// Reach through `wsl.exe` to the distro's own shell.
///
/// `wsl.exe` is a launcher, not a shell: injecting into it directly would never
/// reach the thing that draws the prompt. So we write the integration rcfile on
/// the Windows side and hand `wsl.exe` a command that starts the distro's shell
/// with it — the distro's own startup chain replayed inside it exactly as on any
/// other bash.
///
/// The argv shape is `[<launch flags>] -- sh -c <script>` rather than
/// `-- <shell> --rcfile <path>` because the path only exists as an env var
/// *inside* the distro after `WSLENV` translation, and `wsl.exe` execs its
/// command directly without a shell to expand it. The one-shot `sh` costs a
/// process and `exec`s away immediately.
///
/// Only bash is wired up. A distro on zsh or fish falls through to
/// [`WSL_EXEC_SCRIPT`]'s second arm and launches as a plain login shell — the
/// behavior every WSL pane had before this, just reached one `exec` later. They
/// are integrable the same way (`ZDOTDIR` would need translating too; fish's
/// `-C` needs no file at all), but each needs its own verification pass.
///
/// The rcfile is written unconditionally, before we know the shell — it is a
/// local write into a throwaway dir the terminal already cleans up on drop, and
/// paying it always is what buys the decision being free.
#[cfg(windows)]
fn setup_wsl(args: &[String]) -> Option<Injection> {
let distro = wsl_distro(args);
let dir = throwaway_dir("tty7-wslrc-")?;
let rcfile = dir.join("bashrc");
std::fs::write(&rcfile, bash_rcfile()).ok()?;
// Rebuild the launch flags rather than appending to them: `--` must come
// last, and everything after it is the command. Preserve the distro and
// `--cd` the caller asked for.
let mut argv: Vec<String> = Vec::new();
if let Some(d) = &distro {
argv.push("--distribution".to_string());
argv.push(d.clone());
}
if let Some(cd) = wsl_cd(args) {
argv.push("--cd".to_string());
argv.push(cd);
}
argv.push("--".to_string());
argv.push("sh".to_string());
argv.push("-c".to_string());
argv.push(WSL_EXEC_SCRIPT.to_string());
let mut env = HashMap::new();
env.insert(
WSL_RCFILE_ENV.to_string(),
rcfile.to_string_lossy().into_owned(),
);
env.insert(
"WSLENV".to_string(),
wslenv_with(
std::env::var("WSLENV").ok().as_deref(),
&[&format!("{WSL_RCFILE_ENV}/p")],
),
);
Some(Injection {
env,
args: argv,
replaces_argv: true,
dir: Some(dir),
})
}
/// The `--cd` value from a `wsl.exe` argv. tty7's own launch args pass `--cd ~`
/// so the shell lands in the distro's home rather than a translated Windows path
/// (`core::shells::detect_shells`).
#[cfg_attr(not(windows), allow(dead_code))]
fn wsl_cd(args: &[String]) -> Option<String> {
let mut it = args.iter();
while let Some(a) = it.next() {
if a == "--cd" {
return it.next().cloned();
}
if let Some(v) = a.strip_prefix("--cd=") {
return Some(v.to_string());
}
}
None
}
/// Set up shell integration for a shell tty7 is about to spawn. `program` is
/// the resolved program path/name if the caller already knows it (e.g. the
/// user's configured custom shell, or the default shell resolved from the
@@ -1001,10 +1181,14 @@ fn setup_bash() -> Option<Injection> {
/// `true` when the caller is about to pass user-configured shell args it can't
/// safely override (only affects bash — see [`setup_bash`]).
///
/// `args` are the launch args the caller would otherwise use; only the WSL path
/// reads them (for the distro), and only on Windows.
///
/// Returns the env/arg overrides and the temp dir to clean up, or `None` when
/// the shell isn't supported or anything goes wrong — in which case the
/// terminal launches bare, exactly as before (integration is best-effort).
pub fn setup(program: Option<&str>, has_custom_args: bool) -> Option<Injection> {
#[cfg_attr(not(windows), allow(unused_variables))]
pub fn setup(program: Option<&str>, args: &[String], has_custom_args: bool) -> Option<Injection> {
let mut injection = match shell_kind(program)? {
ShellKind::Zsh => setup_zsh(),
ShellKind::Fish => setup_fish(),
@@ -1015,6 +1199,11 @@ pub fn setup(program: Option<&str>, has_custom_args: bool) -> Option<Injection>
// a custom-arg invocation; launch it bare.
ShellKind::PowerShell if !has_custom_args => setup_powershell(),
ShellKind::PowerShell => None,
// WSL rebuilds the argv around a `--` separator, so — like bash — it
// can't be reconciled with args the user wrote.
#[cfg(windows)]
ShellKind::Wsl if !has_custom_args => setup_wsl(args),
ShellKind::Wsl => None,
}?;
// Reset the install-guard sentinel for the shell we're about to spawn. Each
@@ -1073,28 +1262,23 @@ mod tests {
assert!(!is_our_zdotdir("/tmp/not-tty7-zdotdir-1"));
}
/// End-to-end on a real PTY: spawn the actual Git Bash through the actual
/// `setup` output and assert the full A/B/C/D cycle comes back. Guards the
/// parts no pure test can see — that msys2 bash accepts the rcfile path we
/// hand it, that our hooks survive Git Bash's own `/etc/profile` (which
/// installs a `PROMPT_COMMAND` of its own), and that bash-preexec's DEBUG
/// trap actually fires under a Windows pty. Skips when Git for Windows
/// isn't installed, so it's a no-op on a machine without it.
/// Drive a real shell over a real PTY through `injection`, submit one
/// failing command, and return everything it wrote up to the `D` mark.
///
/// Shared by the Git Bash and WSL end-to-end tests. Two ConPTY behaviors
/// are baked in and must not be "simplified" away:
///
/// - the writer is held for the whole call, because closing a ConPTY's
/// input side raises a console control event that kills the shell with
/// `STATUS_CONTROL_C_EXIT` before it ever reaches a prompt; and
/// - draining happens on a worker thread against a deadline, because a
/// ConPTY master does not reliably EOF when its child exits, so an
/// inline read would block forever rather than fail.
#[cfg(windows)]
#[test]
fn git_bash_reports_the_full_prompt_cycle_over_a_real_pty() {
fn prompt_cycle_over_pty(program: &str, injection: &Injection) -> String {
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use std::io::{Read, Write};
let Some(bash) = crate::core::shells::git_bash_path() else {
eprintln!("skipping: Git for Windows not installed");
return;
};
let bash = bash.to_string_lossy().into_owned();
// `has_custom_args: false` — the dropdown's `-i -l` are tty7's own, so
// the real spawn path reaches setup_bash with them overridable.
let injection = setup(Some(&bash), false).expect("bash integration");
let pty = native_pty_system()
.openpty(PtySize {
rows: 24,
@@ -1103,28 +1287,20 @@ mod tests {
pixel_height: 0,
})
.expect("openpty");
let mut cmd = CommandBuilder::new(&bash);
let mut cmd = CommandBuilder::new(program);
cmd.args(&injection.args);
for (k, v) in &injection.env {
cmd.env(k, v);
}
let mut child = pty.slave.spawn_command(cmd).expect("spawn git bash");
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");
// `false` gives D a non-zero exit code to carry, so a hardcoded 0 in
// the report path can't pass this. `writer` is deliberately held for
// the rest of the test: closing a ConPTY's input side raises a console
// control event, which kills the shell with STATUS_CONTROL_C_EXIT
// before it ever reaches a prompt.
// the report path can't pass these tests.
writer.write_all(b"false\n").expect("write");
writer.flush().expect("flush");
// Drain on a worker: a Windows ConPTY master does not reliably EOF when
// its child exits, so reading inline would block past the shell's own
// exit. The worker feeds chunks over a channel and the test stops at the
// D mark that ends the cycle — or at a deadline, so a shell that never
// reports fails the assert instead of hanging.
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut buf = [0u8; 4096];
@@ -1150,7 +1326,41 @@ mod tests {
let _ = child.kill();
let _ = child.wait();
drop(pty.master);
let text = String::from_utf8_lossy(&out);
String::from_utf8_lossy(&out).into_owned()
}
/// The OSC 7 cwd a captured PTY transcript reported, decoded by the
/// daemon's own parser so emitter and consumer are proven to agree.
#[cfg(windows)]
fn reported_cwd(text: &str) -> PathBuf {
let payload = text
.split("\u{1b}]")
.find(|s| s.starts_with("7;file://"))
.and_then(|s| s.split(['\u{7}', '\u{1b}']).next())
.unwrap_or_else(|| panic!("expected OSC 7; got:\n{text}"));
crate::daemon::pane::parse_osc7(payload.as_bytes())
.unwrap_or_else(|| panic!("daemon could not parse OSC 7 payload {payload:?}"))
}
/// End-to-end on a real PTY: spawn the actual Git Bash through the actual
/// `setup` output and assert the full A/B/C/D cycle comes back. Guards the
/// parts no pure test can see — that msys2 bash accepts the rcfile path we
/// hand it, that our hooks survive Git Bash's own `/etc/profile` (which
/// installs a `PROMPT_COMMAND` of its own), and that bash-preexec's DEBUG
/// trap actually fires under a Windows pty. Skips when Git for Windows
/// isn't installed, so it's a no-op on a machine without it.
#[cfg(windows)]
#[test]
fn git_bash_reports_the_full_prompt_cycle_over_a_real_pty() {
let Some(bash) = crate::core::shells::git_bash_path() else {
eprintln!("skipping: Git for Windows not installed");
return;
};
let bash = bash.to_string_lossy().into_owned();
// `has_custom_args: false` — the dropdown's `-i -l` are tty7's own, so
// the real spawn path reaches setup_bash with them overridable.
let injection = setup(Some(&bash), &[], false).expect("bash integration");
let text = prompt_cycle_over_pty(&bash, &injection);
for mark in ["133;A", "133;B", "133;C", "133;D;1"] {
assert!(
@@ -1162,20 +1372,58 @@ mod tests {
// path, not just the marker's presence: Git Bash's `$PWD` is an msys
// path (`/c/Users/x`) that Windows resolves drive-relative to a
// non-existent `C:\c\Users\x`, which silently disables the git-status
// probe and breaks split/new-tab. Round-tripping through the daemon's
// own parser is what proves the two halves agree.
let payload = text
.split("\u{1b}]")
.find(|s| s.starts_with("7;file://"))
.and_then(|s| s.split(['\u{7}', '\u{1b}']).next())
.unwrap_or_else(|| panic!("expected OSC 7; got:\n{text}"));
let cwd = crate::daemon::pane::parse_osc7(payload.as_bytes())
.unwrap_or_else(|| panic!("daemon could not parse OSC 7 payload {payload:?}"));
// probe and breaks split/new-tab.
let cwd = reported_cwd(&text);
assert!(
cwd.exists(),
"Git Bash reported a cwd the Windows side cannot resolve: {cwd:?} \
(from {payload:?}) — a drive-relative msys path, so `pwd -W` \
translation regressed"
— a drive-relative msys path, so `pwd -W` translation regressed"
);
}
/// End-to-end on a real PTY, through `wsl.exe` into an actual distro.
/// This is the only thing that can show the injection survives the whole
/// chain: `WSLENV` translating the rcfile path to the distro's view of the
/// filesystem, `wsl.exe` passing our `sh -c` through without a shell to
/// mangle its quoting, and the distro's own `/etc/profile` + `~/.bashrc`
/// running before our hooks layer on top.
///
/// Also covers the in-distro shell pick: this machine's distro runs bash, so
/// reaching the marks at all means [`WSL_EXEC_SCRIPT`]'s `case` took its
/// bash arm after surviving Windows argv quoting.
///
/// Skips when WSL isn't installed, so it's a no-op on a machine without it.
#[cfg(windows)]
#[test]
fn wsl_reports_the_full_prompt_cycle_over_a_real_pty() {
let Some(distro) = crate::core::shells::wsl_distros().into_iter().next() else {
eprintln!("skipping: no WSL distributions installed");
return;
};
// Exactly the args the new-tab dropdown produces for this distro.
let args: Vec<String> = vec![
"--distribution".into(),
distro.clone(),
"--cd".into(),
"~".into(),
];
let injection = setup(Some("wsl.exe"), &args, false).expect("wsl integration");
let text = prompt_cycle_over_pty("wsl.exe", &injection);
for mark in ["133;A", "133;B", "133;C", "133;D;1"] {
assert!(
text.contains(mark),
"WSL ({distro}) must report {mark}; got:\n{text}"
);
}
// The distro's cwd is a *Linux* path, and must stay one — translating it
// to something Windows-resolvable would be wrong, not helpful. What
// matters is that the pane is tagged so nothing local consumes it; that
// tagging is asserted in `pane`'s `wsl_remote_context` tests.
let cwd = reported_cwd(&text);
assert!(
cwd.to_string_lossy().starts_with('/'),
"expected the distro's own absolute path, got {cwd:?}"
);
}
@@ -1210,11 +1458,93 @@ mod tests {
}
// Unknown shells (and absolute paths to them) resolve to None.
assert!(shell_kind(Some("/bin/sh")).is_none());
// cmd has no preexec hook of any kind, so it stays unsupported on
// purpose (see the module docs).
assert!(shell_kind(Some("cmd.exe")).is_none());
// cmd has no preexec hook of any kind, and `wsl.exe` is only the
// Windows-side launcher — injecting into it would never reach the
// distro's own shell. Both stay unsupported on purpose.
assert!(shell_kind(Some("wsl.exe")).is_none());
// `wsl.exe` is the launcher, not a shell — it maps to its own kind so
// `setup` can reach through it into the distro.
assert!(matches!(shell_kind(Some("wsl.exe")), Some(ShellKind::Wsl)));
assert!(matches!(shell_kind(Some("wsl")), Some(ShellKind::Wsl)));
}
/// Regression: `setup_wsl` used to probe the distro's login shell with a
/// synchronous `wsl.exe` call. The client waits for the daemon's `Spawn`
/// reply (`terminal::remote::spawn`), so on a cold WSL start — seconds,
/// while the distro boots — that froze the entire window.
///
/// Naming a distro that cannot exist is the deterministic form of the
/// check: if anything asked the distro a question, this could not succeed.
/// A timing bound would only catch it on a cold machine.
#[cfg(windows)]
#[test]
fn wsl_setup_never_contacts_the_distro() {
let args: Vec<String> = vec![
"--distribution".into(),
"tty7-no-such-distro-exists".into(),
"--cd".into(),
"~".into(),
];
let inj = setup(Some("wsl.exe"), &args, false)
.expect("setup must not depend on reaching the distro");
// The launch flags are rebuilt, not appended to, and the command sits
// after `--`.
let sep = inj.args.iter().position(|a| a == "--").expect("`--`");
assert_eq!(
&inj.args[..sep],
&[
"--distribution".to_string(),
"tty7-no-such-distro-exists".to_string(),
"--cd".to_string(),
"~".to_string()
]
);
assert_eq!(inj.args[sep + 1], "sh");
assert_eq!(inj.args[sep + 2], "-c");
// The shell decision is inside the script, not resolved out here.
assert!(inj.args[sep + 3].contains("$SHELL"));
assert!(inj.args[sep + 3].contains("--rcfile"));
assert!(inj.replaces_argv);
}
#[test]
fn wsl_distro_and_cd_are_read_from_either_flag_spelling() {
let long: Vec<String> = ["--distribution", "Ubuntu-24.04", "--cd", "~"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(wsl_distro(&long).as_deref(), Some("Ubuntu-24.04"));
assert_eq!(wsl_cd(&long).as_deref(), Some("~"));
let short: Vec<String> = ["-d", "Debian"].iter().map(|s| s.to_string()).collect();
assert_eq!(wsl_distro(&short).as_deref(), Some("Debian"));
assert_eq!(wsl_cd(&short), None);
let eq: Vec<String> = ["--distribution=Arch", "--cd=/tmp"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(wsl_distro(&eq).as_deref(), Some("Arch"));
assert_eq!(wsl_cd(&eq).as_deref(), Some("/tmp"));
// No distro flag is a valid answer — `wsl.exe` then picks the default.
assert_eq!(wsl_distro(&[]), None);
// A trailing flag with no value must not panic.
assert_eq!(wsl_distro(&["--distribution".to_string()]), None);
}
#[test]
fn wslenv_preserves_the_users_own_entries() {
// Regression guard: overwriting `WSLENV` silently drops whatever the
// user configured, breaking *their* Windows->WSL variable passing.
assert_eq!(
wslenv_with(Some("MYVAR/p:OTHER"), &["TTY7_RC/p"]),
"MYVAR/p:OTHER:TTY7_RC/p"
);
assert_eq!(wslenv_with(None, &["TTY7_RC/p"]), "TTY7_RC/p");
assert_eq!(wslenv_with(Some(""), &["TTY7_RC/p"]), "TTY7_RC/p");
// Already present: left exactly as the user spelled it, not duplicated.
assert_eq!(wslenv_with(Some("TTY7_RC/l"), &["TTY7_RC/p"]), "TTY7_RC/l");
}
/// Git Bash is spawned by its absolute `bash.exe` path, so `.exe` must be
@@ -1575,7 +1905,7 @@ mod tests {
assert!(inj.args[1].contains("__tty7"));
assert!(inj.args[1].contains("133;"));
assert!(inj.env.is_empty());
assert!(!inj.force_non_login);
assert!(!inj.replaces_argv);
// fish needs no throwaway dir on disk.
assert!(inj.dir.is_none());
}
@@ -1589,7 +1919,7 @@ mod tests {
inj.env.get("ZDOTDIR").map(String::as_str),
Some(dir.to_string_lossy().as_ref())
);
assert!(!inj.force_non_login);
assert!(!inj.replaces_argv);
assert!(inj.args.is_empty());
// All four redirector files landed on disk with the expected content.
for (name, body) in zsh_redirectors() {
@@ -1608,7 +1938,7 @@ mod tests {
// argv is `--rcfile <path> -i`, in that order.
assert_eq!(inj.args[0], "--rcfile");
assert_eq!(inj.args[2], "-i");
assert!(inj.force_non_login);
assert!(inj.replaces_argv);
// The rc file on disk matches the generated template.
let rc = std::fs::read_to_string(&inj.args[1]).expect("rcfile written");
assert_eq!(rc, bash_rcfile());
@@ -1618,7 +1948,7 @@ mod tests {
#[test]
fn setup_dispatches_by_shell_and_sets_sentinel() {
// zsh → an injection carrying the "already active" sentinel (empty value).
let inj = setup(Some("zsh"), false).expect("zsh setup");
let inj = setup(Some("zsh"), &[], false).expect("zsh setup");
assert_eq!(
inj.env.get("TTY7_SHELL_INTEGRATION").map(String::as_str),
Some("")
@@ -1628,7 +1958,7 @@ mod tests {
}
// fish → same sentinel, no files.
let inj = setup(Some("fish"), false).expect("fish setup");
let inj = setup(Some("fish"), &[], false).expect("fish setup");
assert!(inj.env.contains_key("TTY7_SHELL_INTEGRATION"));
// bash without custom args → full injection with non-login override.
@@ -1639,28 +1969,28 @@ mod tests {
} else {
"bash"
};
let inj = setup(Some(bash), false).expect("bash setup");
assert!(inj.force_non_login);
let inj = setup(Some(bash), &[], false).expect("bash setup");
assert!(inj.replaces_argv);
assert!(inj.env.contains_key("TTY7_SHELL_INTEGRATION"));
if let Some(d) = inj.dir {
let _ = std::fs::remove_dir_all(d);
}
// bash WITH custom args → we must not second-guess the user: no injection.
assert!(setup(Some(bash), true).is_none());
assert!(setup(Some(bash), &[], true).is_none());
// PowerShell without custom args → encoded-command injection, no files.
let inj = setup(Some("powershell.exe"), false).expect("powershell setup");
let inj = setup(Some("powershell.exe"), &[], false).expect("powershell setup");
assert!(inj.env.contains_key("TTY7_SHELL_INTEGRATION"));
assert!(inj.dir.is_none());
assert!(!inj.force_non_login);
assert!(!inj.replaces_argv);
// PowerShell WITH custom args → `-EncodedCommand` would collide with the
// user's own `-Command`/`-File`, so we launch bare.
assert!(setup(Some("pwsh"), true).is_none());
assert!(setup(Some("pwsh"), &[], true).is_none());
// Unknown shell → no integration at all.
assert!(setup(Some("/bin/sh"), false).is_none());
assert!(setup(Some("/bin/sh"), &[], false).is_none());
}
#[test]
@@ -1684,7 +2014,7 @@ mod tests {
// No throwaway dir, no forced spawn mode, no env of its own.
assert!(inj.env.is_empty());
assert!(inj.dir.is_none());
assert!(!inj.force_non_login);
assert!(!inj.replaces_argv);
}
#[test]
+15 -3
View File
@@ -3733,8 +3733,14 @@ impl Tty7App {
}
};
Some(rgb)
} else if v.remote_context().is_some() {
// A foreground `ssh` typed into a shell: a plain neutral dot.
} else if v
.remote_context()
.is_some_and(|r| r.kind != crate::daemon::protocol::RemoteKind::Wsl)
{
// A foreground `ssh` typed into a shell: a plain neutral dot. The
// kind check matters: a WSL pane also carries a `RemoteContext` (so
// its cwd is treated as foreign — see `local_cwd`), but it is not an
// SSH session and this dot means "SSH".
Some(0x9CA3AF)
} else {
None
@@ -3792,6 +3798,11 @@ impl Tty7App {
cx.notify();
}
/// The focused pane when it is an SSH session of either kind.
///
/// Not every pane carrying a `RemoteContext` is one: a WSL pane has one too,
/// so that its cwd is treated as foreign (see `TerminalView::local_cwd`),
/// and it must not reach anything SSH-shaped from here.
pub(crate) fn active_ssh_pane(
&self,
window: &Window,
@@ -3803,7 +3814,8 @@ impl Tty7App {
.pane
.focused_or_first(window, cx)?;
let pane = pane.read(cx);
Some((pane.pane_id, pane.remote_context()?))
let remote = pane.remote_context()?;
(remote.kind != crate::daemon::protocol::RemoteKind::Wsl).then_some((pane.pane_id, remote))
}
/// The focused pane when it is a *connected native* SSH session — the gate for