feat: first-class WSL session support#65
Open
Bitblade wants to merge 21 commits into
Open
Conversation
Replaces the binary IsRemote flag with a three-valued SessionKind enum so a WSL session can live alongside Local and SSH ones — picking a distro from a combobox (auto-detected via `wsl -l -v`), pointing at a Linux working folder, and optionally pinning a -u user. wsl.exe is the launch process; PTY plumbing is unchanged. Legacy state.json that only carried IsRemote still deserializes cleanly: the IsRemote setter promotes Kind from Local to Ssh, so older files migrate on first load without bespoke conversion code. WSL sessions store their WorkingFolder as a `\\wsl$\<distro>\...` UNC view of the Linux path so Git for Windows, the "Open in Explorer" menu, and the sidebar's git-branch poll all keep working unmodified — git status, dirty state, and repo-root-based accent colors light up the same as for Local sessions. Run-commands (the F5 / chips strip) now also dispatch correctly inside WSL by wrapping the command in `wsl.exe -d ... -- bash -lc '<escaped>'`. The three IsRemote display-label / accent-key ternaries that were drifting across MainWindow + SessionViewModel are consolidated onto three small helpers on ShellSession (DefaultDisplayName / FolderShort / AccentKey) so adding a fourth session kind in future doesn't require chasing call sites.
Mirrors the local Browse… button on the WSL panel. Opens FolderBrowserDialog rooted at \\wsl$\<selected-distro> (the WSL filesystem appears there as a native UNC share); on result, parses the picked path back to a Linux path and, if the user drilled into a different distro than the combo had, updates the combo too. ParseWslUncPath is extracted as an internal static so it can be covered headlessly via InternalsVisibleTo — accepts both the \\wsl$\ and the newer \\wsl.localhost\ prefixes, plus forward-slash variants.
Symmetry with IsRemote (which remains the SSH predicate). Keeps the "remote = ssh" convention intact — just gives WSL its own one-liner so call sites don't have to spell out `Kind == SessionKind.Wsl`.
Git for Windows can't reliably operate on \\wsl$\<distro>\... paths —
the dubious-ownership check refuses to run, and "not a git repo" pops
on perfectly valid repos. Detecting the WSL UNC in the GitService funnel
and dispatching to `wsl.exe -d <distro> -- git -C <linuxPath> <args>`
sidesteps both. Two small translators make the seam invisible:
TranslateUncArgsToLinux: rewrites \\wsl$\<distro>\foo tokens in the
arg string to /foo before invocation, so callers can keep passing
Windows-shaped paths (e.g. `worktree add <unc-target>`).
TranslateLinuxPathsToUnc: walks git stdout for absolute Linux paths
(rev-parse --git-common-dir, worktree list --porcelain) and rewrites
them back to UNC, so the rest of the app sees uniform paths.
Both translators are conservative about what they touch — `refs/heads/foo`
and `M README.md` are passed through untouched per tests.
Three improvements bundled because they're all about the picker landing somewhere useful: - Browse seed = the user's home inside the distro, resolved via `wsl -d <distro> [-u <user>] -- sh -c "cd ~ && pwd"` and cached per (distro, user) so repeated clicks don't re-shell. - Also set FolderBrowserDialog.InitialDirectory in addition to SelectedPath. SelectedPath alone left the COM dialog rooted at the user's last folder (typically Documents) and only pre-typed the UNC in the entry field — clicking Browse felt broken. - The WSL panel now has a visible "User (optional)" column header. Previously the user textbox was identifiable only by tooltip, which read as an empty unlabeled box.
The early-return guard "if NameBox not empty, leave alone" was too greedy: once we auto-filled the name from the first context, it preserved that stale value forever — switching distros wouldn't update the suggestion. Track our own last auto-fill so the guard fires only on truly-user-edited content. Empty box and "still shows our previous suggestion" are both treated as free-to-overwrite; anything else is preserved as user input.
…sion Right-click → "New session here" on a WSL session used to open the dialog in Local mode with the parent's `\\wsl$\…` UNC pre-filled into the working-folder textbox — a layer-cake of subtle wrongness. Now the dialog auto-selects the WSL radio, pre-fills user and Linux working folder from the parent in the constructor, and remembers the parent's distro so PopulateWslDistrosAsync can mark the right combo entry as selected once the async distro list lands.
PowerShell-here on a WSL parent still opens a Windows shell (PS handles the UNC path well enough as cwd, so it works), but the natural shell to ask for from a WSL session is bash inside the same distro. Add a sibling menu item that creates a fresh WSL session pointed at the parent's distro / user / Linux folder. Only shown when the parent IsWsl.
A WSL session whose user picked "New worktree from this branch" got a fresh session at the UNC path of the new worktree — but as a Local kind running PowerShell, so the parent's command (claude, codex, etc.) failed with "not recognized" inside a PS prompt at \\wsl$\<distro>\.... Same fix applies to "New session in sibling worktree" and the sibling- worktree checkbox in the New Session dialog: all three created child sessions without copying Kind/SSH/WSL fields, so a WSL parent quietly demoted its children to Local. Centralized in InheritSessionKindFrom, which also derives the child's WslWorkingFolder from its WorkingFolder UNC (the worktree path the caller built). DuplicateSessionAsync refactored to use the same helper — its inline copy logic was about to drift.
…starts clean Passing the seed UNC to both InitialDirectory and SelectedPath made the COM file dialog show the raw UNC in the bottom "Folder:" textbox — which Windows then renders as a truncated, slash-flipped tail (e.g. "bu/home/bitblade" for \\wsl$\Ubuntu\home\bitblade). InitialDirectory alone navigates the tree to the right place; SelectedPath was only making the visible textbox look broken.
There was a problem hiding this comment.
Pull request overview
Adds first-class WSL session support across the app (new session creation, launching, git integration, and session derivation flows), alongside existing Local and SSH sessions.
Changes:
- Introduces
SessionKind(Local | Ssh | Wsl) onShellSession, retainingIsRemoteas a state.json back-compat shim and adding WSL-specific fields (distro/user/linux working folder). - Extends the New Session dialog UI/logic to support selecting a WSL distro, optional user, and Linux working directory (with UNC-based browsing).
- Routes Git operations for
\\wsl$\...working folders throughwsl.exewith path translation so existing git-dependent features work for WSL sessions.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs | Adds unit coverage for parsing wsl -l -v output and UNC conversion helpers. |
| tests/CodeShellManager.Tests/ShellSessionTests.cs | Adds tests for SessionKind, IsRemote migration behavior, and WSL arg building/display helpers. |
| tests/CodeShellManager.Tests/ShellSessionMigrationTests.cs | Adds explicit state.json migration tests for legacy IsRemote and new Kind serialization. |
| tests/CodeShellManager.Tests/RunInstanceTests.cs | Adds tests for WSL run-command arg building and quote escaping. |
| tests/CodeShellManager.Tests/NewSessionDialogTests.cs | Adds headless tests for WSL UNC parsing helper(s) in the dialog code-behind. |
| tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs | Adds tests for WSL UNC detection and argument/stdout path translation in GitService. |
| src/CodeShellManager/Views/NewSessionDialog.xaml.cs | Implements WSL mode UI behavior: distro discovery, browse-seeding, UNC parsing, and output fields. |
| src/CodeShellManager/Views/NewSessionDialog.xaml | Adds WSL radio + WSL configuration panel controls. |
| src/CodeShellManager/ViewModels/SessionViewModel.cs | Updates display/accent/git-refresh logic to use SessionKind and centralized display helpers. |
| src/CodeShellManager/Services/WslDiscoveryService.cs | Adds discovery of installed WSL distros and home resolution helpers. |
| src/CodeShellManager/Services/RunInstance.cs | Enables “Run” commands to execute inside WSL sessions via wsl.exe ... bash -lc. |
| src/CodeShellManager/Services/GitService.cs | Adds WSL routing through wsl.exe when working directory is a WSL UNC path, with translation helpers. |
| src/CodeShellManager/Models/ShellSession.cs | Adds SessionKind + WSL fields, WSL arg building, and centralized display/accent helpers. |
| src/CodeShellManager/MainWindow.xaml.cs | Wires dialog output to session creation, launching WSL sessions, and kind inheritance for derived sessions. |
| README.md | Documents the new WSL session feature at a high level. |
Comments suppressed due to low confidence (1)
src/CodeShellManager/MainWindow.xaml.cs:690
- InheritSessionKindFrom covers Duplicate/worktree-derived sessions, but session-history reopen uses RecentlyClosedEntry which currently only persists IsRemote + SSH fields (no Kind/WslDistro/WslUser/WslWorkingFolder). As a result, reopening a closed WSL session will likely come back as Local and try to run the command on the UNC path. Consider extending RecentlyClosedEntry + the reopen path to persist/restore Kind and WSL fields (or reusing this helper there).
/// <summary>
/// Propagates a parent session's <see cref="Models.SessionKind"/> and kind-specific
/// fields (SSH host/user/port, WSL distro/user) onto a freshly-created child
/// session. For WSL children it also derives <c>WslWorkingFolder</c> from the
/// child's <c>WorkingFolder</c>, which the worktree code paths set to a
/// <c>\\wsl$\<distro>\…</c> UNC. Without this step a new session spawned
/// from a WSL parent (Duplicate, sibling worktree, new worktree) silently falls
/// back to <see cref="Models.SessionKind.Local"/> and tries to run the parent's
/// command (e.g. <c>claude</c>) inside a Windows PowerShell at the UNC path.
/// </summary>
private static void InheritSessionKindFrom(Models.ShellSession target, Models.ShellSession source)
{
target.Kind = source.Kind;
if (source.Kind == Models.SessionKind.Ssh)
{
target.SshUser = source.SshUser;
target.SshHost = source.SshHost;
target.SshPort = source.SshPort;
target.SshRemoteFolder = source.SshRemoteFolder;
return;
}
if (source.Kind == Models.SessionKind.Wsl)
{
target.WslDistro = source.WslDistro;
target.WslUser = source.WslUser;
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…dlock GetDistrosAsync correctly drains both streams; GetDistroHomeAsync didn't. If wsl.exe writes enough to stderr (transient init notices, a stopped distro error) the child would block on its stderr buffer and the stdout await would never complete — silently falling through to the 3s timeout on every Browse click for WSL sessions. Per Copilot review.
The comment claimed Git for Windows handles WSL UNCs natively — but the preceding GitService routing through wsl.exe exists precisely because it doesn't. Comment now reflects the actual dispatch. Per Copilot review.
The previous guard "Kind != Local → return" was too conservative — RunCommandTemplatesService.SeedFor calls Directory.EnumerateFiles, which works fine on `\\wsl$\<distro>\…` UNCs, and RunInstance already runs WSL sessions' commands via `wsl.exe -- bash -lc`. So a WSL project with a package.json or Cargo.toml at its root now gets its templates the same way a Local one does. Only SSH stays opted out. Per Copilot review.
… WSL RecentlyClosedEntry only captured IsRemote + SSH fields. Closing a WSL session and reopening it via Ctrl+Shift+T (or the Recently Closed list in the New Session dialog) resurrected it as Local at the `\\wsl$\…` UNC path — same failure mode Copilot called out for the worktree path, just on a different code route. - Add Kind / WslDistro / WslUser / WslWorkingFolder to the entry record; FromSession copies them. - Mirror the IsRemote→Ssh migration shim on RecentlyClosedEntry too, so state.json files written before this commit (no Kind key) still render the right subtitle and reopen as SSH. - ReopenClosedSessionAsync copies Kind first (so a WSL entry doesn't get demoted by the IsRemote shim) and the WSL fields second. - Subtitle now keys on Kind so a WSL entry shows `<distro>: <path>`. Per Copilot review.
The unquoted regex stops at whitespace, so a quoted UNC like "\\wsl\$\Ubuntu\home\alice\my repo" (the shape `worktree add <target>` would produce for a Linux path with a space) used to be half-translated and yield a broken git command. Add a two-pass approach: first replace quoted runs (consuming the content up to the closing quote and re-quoting the Linux output), then fall back to the existing unquoted pass for arguments that never needed quoting in the first place. Per Copilot review.
ShellSession.BuildWslArgs, RunInstance.BuildWslArgs, and GitService's RunGitInWslAsync concatenated WslDistro/WslUser/WslWorkingFolder straight into a single argument string. Most distro names are space-free in practice, but Linux working folders genuinely can have spaces (`/home/alice/my proj`) and `wsl --cd` then sees two arguments. Add a conservative QuoteForCmd helper on ShellSession (internal, so tests reach it via InternalsVisibleTo): leaves space-free values alone so existing call sites and tests don't churn, and double-quotes anything that needs it (with embedded `"` escaped as `\"`). Not migrating to ProcessStartInfo.ArgumentList — PseudoTerminal's API takes a single command-line string, and reshaping it is out of scope for this PR. The quoting helper is the surgical fix. Per Copilot review.
If the user navigated out of `\\wsl$\` (e.g. into `C:\Users\…`) and picked there, we silently stuffed the Windows path into WslWorkingFolderBox — `wsl.exe --cd C:\…` then failed at session start with a confusing message. Show a clear MessageBox naming the picked path and explaining which folders are valid, and leave the textbox unchanged so the user can try again. Per Copilot review.
If the user picked a WSL distro but left the Linux Working Folder empty, the launcher omitted `--cd` (so the shell correctly landed in \$HOME) but ToUncPath produced `\\wsl\$\<distro>` — the distro root. GitService then keyed on that UNC, asked git for status at "/", and came back empty even when \$HOME was a real repo. Sidebar branch info silently disappeared. Make Start_Click async and call GetDistroHomeAsync (the cached lookup we already use for the Browse picker) when WslWorkingFolder is empty, so the session's WorkingFolder UNC and its Linux path stay aligned. Best-effort: if WSL is unreachable, fall through to the existing behavior (land in \$HOME, no git info). Per Copilot review.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (2)
src/CodeShellManager/Services/WslDiscoveryService.cs:109
Parse()splits each row on spaces, which breaks if a WSL distro name contains spaces (e.g. an imported distro named "My Distro"). In that casename/state/versiontokens shift and the picker + downstream-d <distro>invocations will be wrong. Consider parsing by fixed columns (based on header offsets) or by taking the last 2 columns asSTATE/VERSIONand treating the remainder as the name (after optional leading*).
// Header row: " NAME STATE VERSION".
// Detect by the presence of the literal "NAME" token and skip.
if (line.TrimStart().StartsWith("NAME", StringComparison.Ordinal)) continue;
var tokens = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (tokens.Length < 2) continue;
bool isDefault = tokens[0] == "*";
int idx = isDefault ? 1 : 0;
if (tokens.Length - idx < 1) continue;
string name = tokens[idx];
string state = tokens.Length - idx >= 2 ? tokens[idx + 1] : "";
int version = 0;
if (tokens.Length - idx >= 3) int.TryParse(tokens[idx + 2], out version);
results.Add(new WslDistro(name, version, isDefault, state));
src/CodeShellManager/Services/WslDiscoveryService.cs:140
GetDistroHomeAsyncbuildswsl.exeargs via string interpolation (-d {distro} -u {user}) without quoting. This will fail for distro/user values containing spaces and also makes argument boundaries ambiguous. Use the same quoting helper as the other wsl.exe call sites (e.g.ShellSession.QuoteForCmd) or switch toProcessStartInfo.ArgumentListto avoid manual quoting entirely.
try
{
string args = $"-d {distro}";
if (!string.IsNullOrEmpty(normalizedUser)) args += $" -u {normalizedUser}";
args += " -- sh -c \"cd ~ && pwd\"";
GetDistrosAsync and GetDistroHomeAsync caught Win32Exception and FileNotFoundException only, but Process.Start can throw InvalidOperationException / PlatformNotSupportedException and the read pipeline can throw IOException — so the doc claim "Never throws — every failure mode collapses to an empty list" wasn't quite accurate. A stray exception here crashes the New Session dialog's Loaded handler, which is a worse outcome than "no WSL distros listed." Broaden the outer catch to Exception and document why. Per Copilot review (round 2).
The token splitter took tokens[idx] as the name, so a `wsl --import "My Distro" …` row was tokenized as name="My", state="Distro", version=0 (the actual "Running"/"Stopped" text isn't a digit, so int.TryParse silently failed). The picker would then list a phantom "My" distro and `wsl -d My` would error at session start. Switch to consuming from the trailing end of the line: VERSION is always the last token, STATE the second-to-last, and everything in between (after an optional leading `*` for the default-distro marker) is the name joined by spaces. Per Copilot review (round 2, suppressed comment).
Last unquoted wsl.exe-arg interpolation we missed in the earlier sweep. With Parse now accepting space-containing distro names, the launcher side has to be ready to receive one — without quoting, a "My Distro" distro would arrive as `-d My Distro` (two args to wsl.exe) and the home-resolution would silently fail. Per Copilot review (round 2, suppressed comment).
Comment on lines
+273
to
+287
| /// <summary> | ||
| /// Builds wsl.exe args for a run executed inside the parent's WSL distro. Pattern: | ||
| /// -d <distro> [-u <user>] [--cd <folder>] -- bash -lc '<escaped>' | ||
| /// </summary> | ||
| internal static string BuildWslArgs(ShellSession parent, string commandLine) | ||
| { | ||
| var sb = new StringBuilder(); | ||
| sb.Append($"-d {ShellSession.QuoteForCmd(parent.WslDistro)}"); | ||
| if (!string.IsNullOrWhiteSpace(parent.WslUser)) | ||
| sb.Append($" -u {ShellSession.QuoteForCmd(parent.WslUser)}"); | ||
| if (!string.IsNullOrWhiteSpace(parent.WslWorkingFolder)) | ||
| sb.Append($" --cd {ShellSession.QuoteForCmd(parent.WslWorkingFolder)}"); | ||
| sb.Append(" -- bash -lc "); | ||
| sb.Append(SingleQuoteEscape(commandLine)); | ||
| return sb.ToString(); |
Comment on lines
+304
to
+318
| /// <summary> | ||
| /// Replaces absolute Linux paths in <paramref name="text"/> (typically git stdout) | ||
| /// with <c>\\wsl$\<distro>\…</c> equivalents so callers see Windows-shaped | ||
| /// paths. Conservative — only matches tokens at start-of-line or after whitespace | ||
| /// to avoid mangling text that happens to contain a slash. | ||
| /// </summary> | ||
| internal static string TranslateLinuxPathsToUnc(string text, string distro) | ||
| { | ||
| if (string.IsNullOrEmpty(text)) return text; | ||
| return Regex.Replace(text, @"(^|[\s=:])(/[^\s'""<>|]+)", m => | ||
| { | ||
| string linuxPath = m.Groups[2].Value; | ||
| string unc = $@"\\wsl$\{distro}" + linuxPath.Replace('/', '\\'); | ||
| return m.Groups[1].Value + unc; | ||
| }, RegexOptions.Multiline); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds WSL as a third session kind alongside Local and SSH. The New Session
dialog gains a
WSLradio that auto-detects installed distros viawsl -l -v,lets the user pick a Linux working folder (with a Browse button rooted at
\\wsl$\<distro>\~), and optionally pins a-u <user>. The session is thenlaunched via
wsl.exe -d <distro> [-u <user>] --cd <linux-path> -- bash -lc '<cmd>',so anything you can normally run inside the distro —
claude,codex,gh copilot, plain bash — Just Works.Git status, repo-root detection, sibling-worktree listing, and
git worktree addall work for WSL sessions: GitService transparently dispatches towsl.exe -d <distro> -- git -C <linuxPath> …when the working folder is a\\wsl$\UNC, translating arg-side and stdout-side paths so the rest of theapp keeps seeing Windows-shaped paths.
Why
WSL is increasingly where Claude Code and other agent CLIs live for Windows
users (Linux toolchain, native filesystem speed, easier shell environments).
Before this PR the only way to drive WSL through CodeShellManager was to add
wsl.exeas a custom launch command — no distro picker, no working-folderfield, no git status, no Open in Explorer / right-click → New worktree, no
sleep/wake of a distinct-distro session, etc.
Implementation notes
SessionKindenum onShellSession:Local | Ssh | Wsl. The legacyIsRemotebool stays as a back-compat shim — its setter promotesLocal → Sshso oldstate.jsonfiles that only carried\"IsRemote\": truemigrate cleanly without any custom converter.
IsWsladded for symmetry.\\wsl$\<distro>\…UNC so thingsthat touch a Windows-shaped path (Explorer, the dormant sidebar item, the
active-pane subtitle) need no special-casing. The Linux-side path lives on
WslWorkingFolderand is what gets passed towsl.exe --cd.RunGitFullAsyncfunnel — every higher-level method (status, branch, rev-parse, worktree
list/add) inherits the routing for free. Two translators handle path
shape:
TranslateUncArgsToLinux: \\wsl$\<distro>\foo → /foo in arg strings,only when the distro matches (other-distro UNCs pass through unchanged).
TranslateLinuxPathsToUnc: absolute Linux paths in stdout → UNC, with aconservative regex that only fires at word boundaries so things like
refs/heads/mainaren't mangled.parent" logic used by Duplicate, sibling-worktree, and the new-worktree
dialog — previously three near-identical inline copies that would have
drifted as soon as a fourth kind landed.
Test plan
\\wsl$\<distro>\<home>→ pick a subfolder → Start. Confirm promptlands inside WSL (
whoami/pwdlook correct).show the branch and dirty state. Run
git statusinside the sessionand the dirty dot should match.
updates. Type a
-uuser — picker re-seeds at that user's home.in WSL mode pre-filled with the parent's distro/user/folder, not Local
mode with the UNC.
inside the same distro+folder.
"not a git repo"), worktree is created via
wsl.exe -- git worktree add, the new session opens as WSL (not PS at the UNC) and runs theparent's command (claude/etc.) successfully.
state.json and relaunch identically.
\"IsRemote\": trueSSH sessions — should still deserialize as SSH(covered by
ShellSessionMigrationTests).with an inline "No WSL distros found…" hint.
Screenshots
(I can add screenshots of the WSL panel + browse picker if helpful — happy
to follow up.)