From 5f7a7719386b76817720dd5efca13d6522183ecc Mon Sep 17 00:00:00 2001 From: thomas Date: Sun, 19 Jul 2026 19:58:27 +0800 Subject: [PATCH 1/4] feat(shell-integration): support WSL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wsl.exe` is a launcher, not a shell, so the integration has to reach through it into the distro. Probe the distro's login shell, write the matching rcfile on the Windows side, and pass its path in via `WSLENV`, whose `/p` flag rewrites it to the distro's own view of the filesystem (`C:\…` -> `/mnt/c/…`) — so the `/mnt` automount root, which is configurable in `/etc/wsl.conf`, is never hardcoded. The argv becomes `[] -- sh -c 'exec --rcfile "$RC" -i'` rather than `-- --rcfile `: the path only exists as an env var *inside* the distro after translation, and `wsl.exe` execs its command directly with no shell to expand it. The one-shot `sh` execs away at once. No new shell code — the distro runs bash, so the existing snippet applies verbatim. Only bash is wired up; zsh and fish inside a distro are reachable the same way but each needs its own verification pass, and declining leaves those panes launching bare, as every WSL pane did before. Tag WSL panes with a new `RemoteKind::Wsl` so `TerminalView::local_cwd` declines their cwd. This is the load-bearing half: the distro reports `/home/me/proj`, which Windows reads not as invalid but as *drive-relative*, resolving to `C:\home\me\proj`. Without the tag, the local git probe, path completion, link resolution and cwd inheritance would all consume it — and on a machine that happens to have such a directory, silently consume the wrong one. The gate itself landed in #133; this adds the third kind to it. Two consequences of that tag needed explicit handling, since nothing matches exhaustively on `RemoteKind` and every miss would have been a silent fall-through: - the foreground-`ssh` poll cleared any context the probe didn't produce, which would have blanked the WSL tag (and the pane's cwd with it) twice a second. It now only replaces the kind it authors. - the tab status dot and `active_ssh_pane` treated "has a RemoteContext" as "is an SSH pane". Both now test the kind. `Injection::force_non_login` is renamed `replaces_argv`: bash needed it because `--rcfile` is ignored for login shells, WSL needs it because the launch flags and command must be reordered around `--`. The mechanism was always "these args replace rather than extend"; only the name was bash's. Verified end-to-end on a real ConPTY into a real distro — the new test asserts the full A/B/C/D cycle comes back through `wsl.exe`, which is the only way to show `WSLENV` translation, `wsl.exe`'s argv passing and the distro's own startup chain all survive together. It shares its harness with the Git Bash test, including the two ConPTY behaviors that harness encodes. 736 tests pass, clippy warning count unchanged. Co-Authored-By: Claude Opus 4.8 --- src/core/shells.rs | 8 + src/daemon/pane.rs | 128 +++++++- src/daemon/protocol.rs | 29 +- src/daemon/shell_integration.rs | 516 +++++++++++++++++++++++++++----- src/ui/app.rs | 19 +- 5 files changed, 600 insertions(+), 100 deletions(-) diff --git a/src/core/shells.rs b/src/core/shells.rs index 55359c3a..581023b2 100644 --- a/src/core/shells.rs +++ b/src/core/shells.rs @@ -308,6 +308,14 @@ pub fn git_bash_path() -> Option { 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 { + 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)] diff --git a/src/daemon/pane.rs b/src/daemon/pane.rs index 43309c3e..9dd2d813 100644 --- a/src/daemon/pane.rs +++ b/src/daemon/pane.rs @@ -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,12 +184,52 @@ fn build_spawn_config( shell: Option, ) -> anyhow::Result { let initial_cwd = initial_working_directory(cwd); + // Computed before `build_shell_command` consumes the spec. + let remote = wsl_remote_context(shell.as_ref()); let (cmd, integration_dir) = build_shell_command(shell, &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 +/// spawn spec 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. +fn wsl_remote_context(shell: Option<&ShellSpec>) -> Option { + if !cfg!(windows) { + return None; + } + let spec = shell?; + let base = std::path::Path::new(&spec.program) + .file_name()? + .to_str()? + .to_ascii_lowercase(); + if base.strip_suffix(".exe").unwrap_or(&base) != "wsl" { + return None; + } + // The distro, when the args name one; otherwise `wsl.exe` picks the default + // and we have no name for it without another probe. + let target = spec + .args + .iter() + .position(|a| a == "--distribution" || a == "-d") + .and_then(|i| spec.args.get(i + 1)) + .cloned() + .unwrap_or_default(); + Some(RemoteContext { + kind: RemoteKind::Wsl, + argv: Vec::new(), + target, }) } @@ -218,12 +258,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 +980,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 +2366,65 @@ 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>| ShellSpec { + 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, + "" + ); + // 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()); + + // 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 +2447,7 @@ mod tests { let injection = shell_integration::Injection { env, args: Vec::new(), - force_non_login: false, + replaces_argv: false, dir: None, }; diff --git a/src/daemon/protocol.rs b/src/daemon/protocol.rs index 7b5348f5..bfa5488e 100644 --- a/src/daemon/protocol.rs +++ b/src/daemon/protocol.rs @@ -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, - /// 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)), diff --git a/src/daemon/shell_integration.rs b/src/daemon/shell_integration.rs index 7b9c362a..ab058a73 100644 --- a/src/daemon/shell_integration.rs +++ b/src/daemon/shell_integration.rs @@ -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, /// Extra argv entries to append after the program (e.g. bash's /// `--rcfile `, fish's `-C