diff --git a/desktop/terminal/terminal-command.helpers.test.ts b/desktop/terminal/terminal-command.helpers.test.ts index f4570088..67d31644 100644 --- a/desktop/terminal/terminal-command.helpers.test.ts +++ b/desktop/terminal/terminal-command.helpers.test.ts @@ -1,5 +1,67 @@ import { describe, expect, it } from 'vitest' -import { resolveTerminalEnv } from './terminal-command.helpers' +import { + getWslTerminalLaunch, + resolveTerminalCommand, + resolveTerminalEnv, +} from './terminal-command.helpers' + +const windowsEnv = { + PATH: 'C:\\Windows\\System32', + USERPROFILE: 'C:\\Users\\Tester', +} + +describe('terminal command helpers', () => { + it('maps WSL UNC project paths to a WSL terminal launch on Windows', () => { + const launch = getWslTerminalLaunch('\\\\wsl.localhost\\Ubuntu-24.04\\home\\me\\project', { + platform: 'win32', + env: windowsEnv, + }) + + expect(launch).toEqual({ + distro: 'Ubuntu-24.04', + linuxPath: '/home/me/project', + windowsSpawnCwd: 'C:\\Users\\Tester', + }) + }) + + it('supports the legacy wsl$ UNC prefix', () => { + const command = resolveTerminalCommand( + { + projectId: '\\\\wsl$\\Ubuntu\\home\\me\\repo', + cwd: '\\\\wsl$\\Ubuntu\\home\\me\\repo', + launchMode: 'shell', + cols: 80, + rows: 24, + }, + { platform: 'win32', env: windowsEnv }, + ) + + expect(command.shell.endsWith('wsl.exe')).toBe(true) + expect(command).toMatchObject({ + args: ['-d', 'Ubuntu', '--cd', '/home/me/repo'], + cwd: 'C:\\Users\\Tester', + }) + }) + + it('keeps native Windows projects on the normal Windows shell', () => { + const command = resolveTerminalCommand( + { + projectId: 'C:\\Projects\\app', + cwd: 'C:\\Projects\\app', + launchMode: 'shell', + cols: 80, + rows: 24, + }, + { platform: 'win32', env: { COMSPEC: 'C:\\Windows\\System32\\cmd.exe' } }, + ) + + expect(command).toEqual({ + shell: 'C:\\Windows\\System32\\cmd.exe', + args: [], + cwd: undefined, + }) + }) +}) describe('resolveTerminalEnv', () => { it('advertises a portable xterm truecolor baseline for shell sessions', () => { diff --git a/desktop/terminal/terminal-command.helpers.ts b/desktop/terminal/terminal-command.helpers.ts index 5add0363..71783974 100644 --- a/desktop/terminal/terminal-command.helpers.ts +++ b/desktop/terminal/terminal-command.helpers.ts @@ -4,6 +4,8 @@ import { getPersistedSessionPath } from '../../shared/session-paths.ts' import type { TerminalOpenRequest } from '../../shared/terminal-contracts.ts' import { getBundledThemes } from '../bundled-themes.ts' +const wslUncPathPattern = /^\\\\(?:wsl\$|wsl\.localhost)\\([^\\/]+)([\\/].*)?$/i + function getProcessEnvironmentVariable(name: string) { return process.env[name] } @@ -20,6 +22,35 @@ function getEnvironmentVariable(env: NodeJS.ProcessEnv, name: string) { return env[name] } +function getSafeWindowsSpawnCwd(env: NodeJS.ProcessEnv) { + return ( + getEnvironmentVariable(env, 'USERPROFILE') || + getEnvironmentVariable(env, 'SystemRoot') || + process.cwd() + ) +} + +export function getWslTerminalLaunch( + cwd: string | null | undefined, + options?: { platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv }, +) { + const platform = options?.platform ?? process.platform + if (platform !== 'win32' || !cwd) return null + + const match = cwd.match(wslUncPathPattern) + if (!match) return null + + const distro = match[1] + const rawLinuxPath = match[2] ?? '' + if (!distro) return null + + return { + distro, + linuxPath: rawLinuxPath ? rawLinuxPath.replaceAll('\\', '/') : '/', + windowsSpawnCwd: getSafeWindowsSpawnCwd(options?.env ?? process.env), + } +} + function getPiSessionCommandArgs(sessionPath: string | null | undefined) { const persistedSessionPath = getPersistedSessionPath(sessionPath) const args = persistedSessionPath ? ['--session', persistedSessionPath] : [] @@ -74,11 +105,37 @@ export function resolveTerminalCommand( ) { const platform = options?.platform ?? process.platform const env = options?.env ?? process.env + const wslLaunch = getWslTerminalLaunch(request.cwd, { platform, env }) if (request.launchMode === 'pi-session') { + if (wslLaunch) { + return { + shell: findExecutable('wsl.exe', getEnvironmentVariable(env, 'PATH') ?? ''), + args: [ + '-d', + wslLaunch.distro, + '--cd', + wslLaunch.linuxPath, + '--', + 'pi', + ...getPiSessionCommandArgs(request.sessionPath), + ], + cwd: wslLaunch.windowsSpawnCwd, + } + } + return { shell: getPiSessionShell(platform, env), args: getPiSessionCommandArgs(request.sessionPath), + cwd: undefined, + } + } + + if (wslLaunch) { + return { + shell: findExecutable('wsl.exe', getEnvironmentVariable(env, 'PATH') ?? ''), + args: ['-d', wslLaunch.distro, '--cd', wslLaunch.linuxPath], + cwd: wslLaunch.windowsSpawnCwd, } } @@ -86,12 +143,14 @@ export function resolveTerminalCommand( return { shell: getEnvironmentVariable(env, 'COMSPEC') || 'powershell.exe', args: [] as string[], + cwd: undefined, } } return { shell: getEnvironmentVariable(env, 'SHELL') || '/bin/bash', args: ['-i'], + cwd: undefined, } } diff --git a/desktop/terminal/terminal-process.ts b/desktop/terminal/terminal-process.ts index 4adb3c4a..e3b127a1 100644 --- a/desktop/terminal/terminal-process.ts +++ b/desktop/terminal/terminal-process.ts @@ -34,7 +34,7 @@ export async function startProcess(record: TerminalSessionRecord, reason: 'start const processHandle = await adapter.spawn({ shell: command.shell, args: command.args, - cwd: record.snapshot.cwd, + cwd: command.cwd ?? record.snapshot.cwd, cols: record.snapshot.cols, rows: record.snapshot.rows, env: resolveTerminalEnv(request), diff --git a/src/app/settings/settings/settingsDescriptorProjects.tsx b/src/app/settings/settings/settingsDescriptorProjects.tsx index a67fe0ac..83d3ebc1 100644 --- a/src/app/settings/settings/settingsDescriptorProjects.tsx +++ b/src/app/settings/settings/settingsDescriptorProjects.tsx @@ -77,8 +77,8 @@ export function buildProjectsSettingsDescriptors({ className={cn(settingsInputClass, 'w-full pl-9')} placeholder={ controller.resolvedPiDirectory - ? `Default: ${controller.resolvedPiDirectory}` - : 'Default: ~/.pi/agent' + ? `Default: ${controller.resolvedPiDirectory} (useful for WSL)` + : 'Default: ~/.pi/agent (useful for WSL)' } aria-label="Custom Pi directory" />