Skip to content

Commit 2c8d878

Browse files
committed
feat: add WSL Windows Terminal support; fix copilot binary lookup
WSL support (closes #8): - Detect Windows Terminal (wt.exe) when running under WSL - Launch sessions via wt.exe with wslpath translation - Support tab, window, and pane launch styles in WSL - Cache isWSL() with sync.Once for performance - Add 25+ tests covering functional and security scenarios Copilot binary lookup: - Add exec.LookPath fallback in findCopilotBinary - Add PATH isolation in related tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d472c3a commit 2c8d878

5 files changed

Lines changed: 649 additions & 14 deletions

File tree

‎internal/data/chronicle_windows.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package data
55
import (
66
"io"
77
"os"
8+
"os/exec"
89
"path/filepath"
910
"sync"
1011

@@ -51,6 +52,11 @@ func startPTY(binary string) (io.ReadWriteCloser, error) {
5152

5253
// findCopilotBinary returns the path to copilot.exe on Windows.
5354
func findCopilotBinary() string {
55+
// Try PATH first (matches Unix behavior).
56+
if p, err := exec.LookPath("copilot"); err == nil {
57+
return p
58+
}
59+
5460
candidates := []string{
5561
filepath.Join(os.Getenv("ProgramFiles"), "nodejs", "node_modules",
5662
"@github", "copilot", "node_modules", "@github", "copilot-win32-x64", "copilot.exe"),

‎internal/data/mq_coverage_test.go‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,9 +166,10 @@ func TestLastReindexTime_EmptyWAL(t *testing.T) {
166166
// ---------------------------------------------------------------------------
167167

168168
func TestFindCopilotBinary_NoCandidates(t *testing.T) {
169-
// Set environment to non-existent paths.
169+
// Set environment to non-existent paths and isolate PATH.
170170
t.Setenv("ProgramFiles", filepath.Join(t.TempDir(), "nonexistent"))
171171
t.Setenv("APPDATA", "")
172+
t.Setenv("PATH", t.TempDir())
172173

173174
got := findCopilotBinary()
174175
if got != "" {
@@ -179,6 +180,7 @@ func TestFindCopilotBinary_NoCandidates(t *testing.T) {
179180
func TestFindCopilotBinary_WithAPPDATA(t *testing.T) {
180181
tmp := t.TempDir()
181182
t.Setenv("ProgramFiles", filepath.Join(tmp, "nonexistent"))
183+
t.Setenv("PATH", filepath.Join(tmp, "emptypath"))
182184

183185
// Create the APPDATA candidate path.
184186
appdata := filepath.Join(tmp, "appdata")
@@ -206,6 +208,7 @@ func TestFindCopilotBinary_WithProgramFiles(t *testing.T) {
206208
progFiles := filepath.Join(tmp, "Program Files")
207209
t.Setenv("ProgramFiles", progFiles)
208210
t.Setenv("APPDATA", "")
211+
t.Setenv("PATH", filepath.Join(tmp, "emptypath"))
209212

210213
candidatePath := filepath.Join(progFiles, "nodejs", "node_modules",
211214
"@github", "copilot", "node_modules", "@github", "copilot-win32-x64")
@@ -223,6 +226,28 @@ func TestFindCopilotBinary_WithProgramFiles(t *testing.T) {
223226
}
224227
}
225228

229+
func TestFindCopilotBinary_ViaPath(t *testing.T) {
230+
tmp := t.TempDir()
231+
t.Setenv("ProgramFiles", filepath.Join(tmp, "nonexistent"))
232+
t.Setenv("APPDATA", "")
233+
234+
// Place a copilot.exe on PATH.
235+
binDir := filepath.Join(tmp, "bin")
236+
if err := os.MkdirAll(binDir, 0o755); err != nil {
237+
t.Fatalf("creating bin dir: %v", err)
238+
}
239+
fakeBinary := filepath.Join(binDir, "copilot.exe")
240+
if err := os.WriteFile(fakeBinary, []byte("fake"), 0o755); err != nil {
241+
t.Fatalf("writing fake binary: %v", err)
242+
}
243+
t.Setenv("PATH", binDir)
244+
245+
got := findCopilotBinary()
246+
if got != fakeBinary {
247+
t.Errorf("findCopilotBinary() = %q, want %q", got, fakeBinary)
248+
}
249+
}
250+
226251
// ---------------------------------------------------------------------------
227252
// Maintain — 56% coverage, test error branches
228253
// ---------------------------------------------------------------------------

‎internal/platform/paths.go‎

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"path/filepath"
99
"runtime"
1010
"strings"
11+
"sync"
1112
)
1213

1314
const (
@@ -58,20 +59,30 @@ func SessionStorePath() (string, error) {
5859
const wslMountRoot = "/mnt/c/Users"
5960

6061
// isWSL reports whether the current process is running inside Windows
61-
// Subsystem for Linux.
62+
// Subsystem for Linux. The result is cached after the first call since
63+
// the WSL status cannot change during a process lifetime.
6264
func isWSL() bool {
63-
// WSL2 (and recent WSL1) always set WSL_DISTRO_NAME.
64-
if os.Getenv("WSL_DISTRO_NAME") != "" {
65-
return true
66-
}
67-
// Older WSL1 may not set the env var; fall back to /proc/version.
68-
data, err := os.ReadFile("/proc/version")
69-
if err != nil {
70-
return false
71-
}
72-
return strings.Contains(strings.ToLower(string(data)), "microsoft")
65+
wslOnce.Do(func() {
66+
// WSL2 (and recent WSL1) always set WSL_DISTRO_NAME.
67+
if os.Getenv("WSL_DISTRO_NAME") != "" {
68+
wslCached = true
69+
return
70+
}
71+
// Older WSL1 may not set the env var; fall back to /proc/version.
72+
data, err := os.ReadFile("/proc/version")
73+
if err != nil {
74+
return
75+
}
76+
wslCached = strings.Contains(strings.ToLower(string(data)), "microsoft")
77+
})
78+
return wslCached
7379
}
7480

81+
var (
82+
wslOnce sync.Once
83+
wslCached bool
84+
)
85+
7586
// findWindowsSessionStore scans Windows user-profile directories under the
7687
// default WSL mount for a Copilot session store database.
7788
func findWindowsSessionStore() string {

‎internal/platform/shell.go‎

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,13 @@ func DefaultTerminal() string {
332332
case "darwin":
333333
return "Terminal.app"
334334
default:
335+
// Inside WSL, prefer Windows Terminal when wt.exe is available
336+
// since it's the terminal the user is actually sitting in.
337+
if isWSL() {
338+
if _, err := exec.LookPath("wt.exe"); err == nil {
339+
return termWindowsTerminal
340+
}
341+
}
335342
if terms := detectLinuxTerminals(); len(terms) > 0 {
336343
return terms[0].Name
337344
}
@@ -371,7 +378,7 @@ func LaunchSession(shell ShellInfo, sessionID string, cfg ResumeConfig) error {
371378
case "darwin":
372379
return launchDarwinSession(shell, resumeCmd, cfg.Terminal, cwd, cfg.LaunchStyle == LaunchStyleWindow)
373380
default:
374-
return launchLinuxSession(shell, resumeCmd, cfg.Terminal, cwd)
381+
return launchLinuxSession(shell, resumeCmd, cfg.Terminal, cwd, cfg.LaunchStyle, cfg.PaneDirection)
375382
}
376383
}
377384

@@ -672,7 +679,12 @@ func escapeAppleScript(s string) string {
672679
return s
673680
}
674681

675-
func launchLinuxSession(shell ShellInfo, resumeCmd string, terminal string, cwd string) error {
682+
func launchLinuxSession(shell ShellInfo, resumeCmd string, terminal string, cwd string, launchStyle string, paneDirection string) error {
683+
// Inside WSL, route to Windows Terminal when configured.
684+
if terminal == termWindowsTerminal && isWSL() {
685+
return launchWSLWindowsTerminal(shell, resumeCmd, cwd, launchStyle, paneDirection)
686+
}
687+
676688
// Supported terminal emulators and their argument patterns.
677689
terminals := []struct {
678690
name string
@@ -705,6 +717,13 @@ func launchLinuxSession(shell ShellInfo, resumeCmd string, terminal string, cwd
705717
}
706718
}
707719

720+
// Auto-detect: try Windows Terminal first if running under WSL.
721+
if isWSL() {
722+
if _, err := exec.LookPath("wt.exe"); err == nil {
723+
return launchWSLWindowsTerminal(shell, resumeCmd, cwd, launchStyle, paneDirection)
724+
}
725+
}
726+
708727
// Auto-detect: try terminals in order of popularity.
709728
for _, t := range terminals {
710729
if p, err := exec.LookPath(t.name); err == nil {
@@ -719,6 +738,76 @@ func launchLinuxSession(shell ShellInfo, resumeCmd string, terminal string, cwd
719738
return errors.New("no supported terminal emulator found; tried alacritty, kitty, wezterm, gnome-terminal, konsole, xfce4-terminal, xterm")
720739
}
721740

741+
// launchWSLWindowsTerminal launches a session via wt.exe from within WSL.
742+
// It translates the working directory from a Linux path to a Windows path
743+
// using wslpath and constructs wt.exe arguments for the requested launch
744+
// style (tab, window, or pane).
745+
func launchWSLWindowsTerminal(shell ShellInfo, resumeCmd string, cwd string, launchStyle string, paneDirection string) error {
746+
p, err := exec.LookPath("wt.exe")
747+
if err != nil {
748+
return fmt.Errorf("wt.exe not found on PATH: %w", err)
749+
}
750+
751+
// Translate cwd to a Windows path (empty string on failure).
752+
var winCwd string
753+
if cwd != "" {
754+
winCwd, _ = wslToWindowsPath(cwd)
755+
}
756+
757+
distro := os.Getenv("WSL_DISTRO_NAME")
758+
args := buildWSLWTArgs(shell, resumeCmd, winCwd, distro, launchStyle, paneDirection)
759+
760+
cmd := exec.Command(p, args...)
761+
return cmd.Start()
762+
}
763+
764+
// buildWSLWTArgs constructs the wt.exe argument list for launching a WSL
765+
// session inside Windows Terminal. It is separated from launchWSLWindowsTerminal
766+
// so it can be tested without requiring wt.exe or a WSL environment.
767+
func buildWSLWTArgs(shell ShellInfo, resumeCmd, winCwd, distro, launchStyle, paneDirection string) []string {
768+
var args []string
769+
switch launchStyle {
770+
case LaunchStyleWindow:
771+
args = append(args, "-w", "new", "new-tab")
772+
case LaunchStylePane:
773+
args = append(args, "-w", "0", "split-pane")
774+
if paneDirection != "" && paneDirection != "auto" {
775+
args = append(args, "--direction", paneDirection)
776+
}
777+
default:
778+
args = append(args, "-w", "0", "new-tab")
779+
}
780+
781+
if winCwd != "" {
782+
args = append(args, "--startingDirectory", winCwd)
783+
}
784+
785+
// wt.exe wsl.exe -d <distro> -- <shell> -c <resumeCmd>
786+
if distro != "" {
787+
args = append(args, "wsl.exe", "-d", distro, "--", shell.Path, "-c", resumeCmd)
788+
} else {
789+
args = append(args, "wsl.exe", "--", shell.Path, "-c", resumeCmd)
790+
}
791+
792+
return args
793+
}
794+
795+
// wslToWindowsPath translates a Linux filesystem path to a Windows path
796+
// using the wslpath utility available inside WSL.
797+
func wslToWindowsPath(linuxPath string) (string, error) {
798+
cmd := exec.Command("wslpath", "-w", linuxPath)
799+
out, err := cmd.Output()
800+
if err != nil {
801+
// Include stderr from wslpath for better diagnostics.
802+
var exitErr *exec.ExitError
803+
if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 {
804+
return "", fmt.Errorf("wslpath: %s: %w", strings.TrimSpace(string(exitErr.Stderr)), err)
805+
}
806+
return "", fmt.Errorf("wslpath: %w", err)
807+
}
808+
return strings.TrimSpace(string(out)), nil
809+
}
810+
722811
// ---------------------------------------------------------------------------
723812
// Terminal detection
724813
// ---------------------------------------------------------------------------
@@ -758,6 +847,15 @@ func detectDarwinTerminals() []TerminalInfo {
758847

759848
func detectLinuxTerminals() []TerminalInfo {
760849
var terms []TerminalInfo
850+
851+
// Inside WSL, include Windows Terminal when wt.exe is available
852+
// via the WSL-Windows interop PATH.
853+
if isWSL() {
854+
if _, err := exec.LookPath("wt.exe"); err == nil {
855+
terms = append(terms, TerminalInfo{Name: termWindowsTerminal})
856+
}
857+
}
858+
761859
candidates := []string{
762860
"alacritty", "kitty", "wezterm",
763861
"gnome-terminal", "konsole", "xfce4-terminal", "xterm",

0 commit comments

Comments
 (0)