Fix WSL terminal settings parity (#2446)

This commit is contained in:
Jinwoo Hong 2026-05-20 17:33:07 -04:00 committed by GitHub
parent 288d96be03
commit 6abf031d66
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1894 additions and 157 deletions

View File

@ -0,0 +1,203 @@
import type { CliInstallStatus } from '../../shared/cli-install-types'
import { WslCliInstaller, _internals } from './wsl-cli-installer'
import { describe, expect, it, vi } from 'vitest'
function makeHostStatus(launcherPath = 'C:\\Users\\me\\AppData\\Local\\Orca\\bin\\orca.cmd') {
return {
platform: 'win32',
commandName: 'orca',
commandPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\bin\\orca.cmd',
pathDirectory: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\bin',
pathConfigured: true,
launcherPath,
installMethod: 'wrapper',
supported: true,
state: 'installed',
currentTarget: launcherPath,
unsupportedReason: null,
detail: null
} satisfies CliInstallStatus
}
function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin = true) {
const commandPath = '/home/alice/.local/bin/orca'
const bridgePath = '/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
const files = new Map<string, string>()
if (initialFile !== null) {
files.set(commandPath, initialFile)
files.set(bridgePath, _internals.buildWslBridgeScript())
}
const calls: string[] = []
const runner = vi.fn(async (_distro: string, command: string) => {
calls.push(command)
if (command.includes('printf %s "$HOME"')) {
return '/home/alice'
}
if (command.includes('command -v powershell.exe')) {
return 'yes'
}
if (command.includes('case ":$PATH:"')) {
return pathIncludesLocalBin ? 'yes' : 'no'
}
if (command.includes('cat > "$command_tmp"')) {
const launcher =
command.match(/cat > "\$command_tmp" <<'ORCA_WSL_CLI'\n([\s\S]*)\nORCA_WSL_CLI/)?.[1] ?? ''
const bridge =
command.match(
/cat > "\$bridge_tmp" <<'ORCA_WSL_BRIDGE'\n([\s\S]*)\nORCA_WSL_BRIDGE/
)?.[1] ?? ''
files.set(commandPath, launcher)
files.set(bridgePath, bridge)
return ''
}
if (command.includes('rm -f')) {
if (
files.has(bridgePath) &&
!files.get(bridgePath)?.includes('# Orca managed WSL CLI PowerShell bridge')
) {
throw new Error('__ORCA_CONFLICT__')
}
files.delete(commandPath)
files.delete(bridgePath)
return ''
}
if (command.includes('cat ')) {
if (command.includes(commandPath)) {
return files.get(commandPath) ?? '__ORCA_MISSING__'
}
if (command.includes(bridgePath)) {
return files.get(bridgePath) ?? '__ORCA_MISSING__'
}
}
throw new Error(`Unexpected WSL command: ${command}`)
})
return {
runner,
calls,
getBridge: () => files.get(bridgePath) ?? null,
getFile: () => files.get(commandPath) ?? null
}
}
describe('WslCliInstaller', () => {
it('installs a WSL launcher that forwards to the Windows Orca launcher', async () => {
const wsl = createWslRunner()
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus() },
wslRunner: wsl.runner
})
await expect(installer.getStatus()).resolves.toMatchObject({
state: 'not_installed',
commandPath: '/home/alice/.local/bin/orca'
})
const installed = await installer.install()
expect(installed).toMatchObject({
state: 'installed',
pathConfigured: true,
launcherPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\bin\\orca.cmd'
})
expect(wsl.getFile()).toBe(
_internals.buildWslLauncher(
'C:\\Users\\me\\AppData\\Local\\Orca\\bin\\orca.cmd',
'/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
)
)
expect(wsl.getBridge()).toBe(_internals.buildWslBridgeScript())
})
it('reports installed WSL launchers whose bin directory is missing from PATH', async () => {
const launcher = _internals.buildWslLauncher(
'C:\\Orca\\orca.cmd',
'/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
)
const wsl = createWslRunner(launcher, false)
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus('C:\\Orca\\orca.cmd') },
wslRunner: wsl.runner
})
await expect(installer.getStatus()).resolves.toMatchObject({
state: 'installed',
pathConfigured: false,
detail: expect.stringContaining('not on PATH')
})
})
it('refuses to replace an unmanaged WSL command', async () => {
const wsl = createWslRunner('#!/usr/bin/env bash\necho elsewhere\n')
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus() },
wslRunner: wsl.runner
})
await expect(installer.getStatus()).resolves.toMatchObject({ state: 'conflict' })
await expect(installer.install()).rejects.toThrow('Refusing to replace')
})
it('removes a managed WSL launcher', async () => {
const wsl = createWslRunner(
_internals.buildWslLauncher(
'C:\\Orca\\orca.cmd',
'/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
)
)
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus('C:\\Orca\\orca.cmd') },
wslRunner: wsl.runner
})
await expect(installer.remove()).resolves.toMatchObject({ state: 'not_installed' })
expect(wsl.getFile()).toBeNull()
})
it('generates a launcher that forwards arguments through a PowerShell file bridge', () => {
const launcher = _internals.buildWslLauncher(
'C:\\Program Files\\Orca\\orca.cmd',
'/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
)
const bridge = _internals.buildWslBridgeScript()
expect(launcher).toContain('powershell.exe -NoProfile -ExecutionPolicy Bypass -File')
expect(launcher).toContain('"$ORCA_WIN_LAUNCHER" "$@"')
expect(launcher).not.toContain('-Command')
expect(bridge).toContain('[Parameter(ValueFromRemainingArguments=$true)]')
expect(bridge).toContain('& $OrcaLauncher @ForwardArgs')
expect(bridge).toContain('catch')
expect(bridge).toContain('exit 1')
})
it('refuses to remove an old managed launcher when the bridge path is user-owned', async () => {
const oldLauncher = _internals.buildWslLauncher(
'C:\\Old\\orca.cmd',
'/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
)
const wsl = createWslRunner(oldLauncher)
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus('C:\\Orca\\orca.cmd') },
wslRunner: async (distro, command) => {
if (command.includes('cat /home/alice/.local/share/orca/orca-wsl-bridge.ps1')) {
return 'user bridge'
}
if (command.includes('rm -f')) {
throw new Error('__ORCA_CONFLICT__')
}
return wsl.runner(distro, command)
}
})
await expect(installer.remove()).rejects.toThrow('__ORCA_CONFLICT__')
})
})

View File

@ -0,0 +1,352 @@
/* eslint-disable max-lines -- Why: WSL CLI status/install/remove share one state machine;
splitting the installer would separate conflict checks from the operations they guard. */
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import type { CliInstallStatus } from '../../shared/cli-install-types'
import { getDefaultWslDistro } from '../wsl'
import { CliInstaller } from './cli-installer'
import {
buildSafeRemoveCommand,
buildSafeReplaceGuard,
buildWslBridgeScript,
buildWslLauncher,
getBridgePathFromCommandPath,
getPosixDirname,
getWslBridgeMarker,
getWslLauncherMarker,
parseManagedLauncherTarget,
quoteShell
} from './wsl-cli-scripts'
const execFileAsync = promisify(execFile)
const MANAGED_MARKER = getWslLauncherMarker()
const BRIDGE_MANAGED_MARKER = getWslBridgeMarker()
type WslCliInstallerOptions = {
platform?: NodeJS.Platform
distro?: string | null
hostInstaller?: Pick<CliInstaller, 'getStatus'>
wslRunner?: (distro: string, command: string) => Promise<string>
}
export class WslCliInstaller {
private readonly platform: NodeJS.Platform
private readonly distro: string | null
private readonly hostInstaller: Pick<CliInstaller, 'getStatus'>
private readonly wslRunner: (distro: string, command: string) => Promise<string>
constructor(options: WslCliInstallerOptions = {}) {
this.platform = options.platform ?? process.platform
this.distro = options.distro === undefined ? getDefaultWslDistro() : options.distro
this.hostInstaller = options.hostInstaller ?? new CliInstaller()
this.wslRunner = options.wslRunner ?? runWslCommand
}
async getStatus(): Promise<CliInstallStatus> {
const ready = await this.resolveReadyState()
if ('status' in ready) {
return ready.status
}
const content = await this.readCommandFile(ready.distro, ready.commandPath)
if (content === null) {
return this.buildStatus({
distro: ready.distro,
commandPath: ready.commandPath,
launcherPath: ready.launcherPath,
state: 'not_installed',
currentTarget: null,
pathConfigured: ready.pathConfigured,
detail: `Register ${ready.commandPath} to use Orca from WSL.`
})
}
if (content === 'not_file') {
return this.buildStatus({
distro: ready.distro,
commandPath: ready.commandPath,
launcherPath: ready.launcherPath,
state: 'conflict',
currentTarget: null,
pathConfigured: ready.pathConfigured,
detail: `${ready.commandPath} exists but is not an Orca launcher script.`
})
}
const expected = buildWslLauncher(ready.launcherPath, ready.bridgePath)
const managed = content.includes(MANAGED_MARKER)
const currentTarget = managed ? parseManagedLauncherTarget(content) : null
if (content === expected) {
const bridgeContent = await this.readCommandFile(ready.distro, ready.bridgePath)
const expectedBridge = buildWslBridgeScript()
if (bridgeContent === expectedBridge) {
return this.buildStatus({
distro: ready.distro,
commandPath: ready.commandPath,
launcherPath: ready.launcherPath,
state: 'installed',
currentTarget,
pathConfigured: ready.pathConfigured,
detail: `Registered in ${ready.distro} at ${ready.commandPath}.`
})
}
const bridgeManaged =
typeof bridgeContent === 'string' && bridgeContent.includes(BRIDGE_MANAGED_MARKER)
return this.buildStatus({
distro: ready.distro,
commandPath: ready.commandPath,
launcherPath: ready.launcherPath,
state: bridgeContent === null || bridgeManaged ? 'stale' : 'conflict',
currentTarget,
pathConfigured: ready.pathConfigured,
detail:
bridgeContent === null || bridgeManaged
? `${ready.commandPath} is missing its PowerShell bridge.`
: `${ready.bridgePath} exists but is not managed by Orca.`
})
}
return this.buildStatus({
distro: ready.distro,
commandPath: ready.commandPath,
launcherPath: ready.launcherPath,
state: managed ? 'stale' : 'conflict',
currentTarget,
pathConfigured: ready.pathConfigured,
detail: managed
? `${ready.commandPath} points to a different Orca launcher.`
: `${ready.commandPath} exists but is not managed by Orca.`
})
}
async install(): Promise<CliInstallStatus> {
const status = await this.getStatus()
if (!status.supported || !status.commandPath || !status.launcherPath) {
throw new Error(status.detail ?? 'WSL CLI registration is unavailable.')
}
if (status.state === 'conflict') {
throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}.`)
}
await this.run(
this.distro as string,
[
'set -euo pipefail',
`mkdir -p ${quoteShell(status.pathDirectory as string)}`,
`mkdir -p ${quoteShell(getPosixDirname(getBridgePathFromCommandPath(status.commandPath)))}`,
`command_tmp=${quoteShell(`${status.commandPath}.tmp`)}.$$`,
`bridge_path=${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`,
'bridge_tmp="${bridge_path}.tmp.$$"',
'cleanup() { rm -f "$command_tmp" "$bridge_tmp"; }',
'trap cleanup EXIT',
buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER),
buildSafeReplaceGuard(
getBridgePathFromCommandPath(status.commandPath),
BRIDGE_MANAGED_MARKER
),
`cat > "$command_tmp" <<'ORCA_WSL_CLI'`,
buildWslLauncher(status.launcherPath, getBridgePathFromCommandPath(status.commandPath)),
'ORCA_WSL_CLI',
`cat > "$bridge_tmp" <<'ORCA_WSL_BRIDGE'`,
buildWslBridgeScript(),
'ORCA_WSL_BRIDGE',
'chmod 755 "$command_tmp"',
'chmod 644 "$bridge_tmp"',
buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER),
buildSafeReplaceGuard(
getBridgePathFromCommandPath(status.commandPath),
BRIDGE_MANAGED_MARKER
),
`mv -f "$bridge_tmp" ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`,
`mv -f "$command_tmp" ${quoteShell(status.commandPath)}`,
'trap - EXIT'
].join('\n')
)
return this.getStatus()
}
async remove(): Promise<CliInstallStatus> {
const status = await this.getStatus()
if (!status.supported || !status.commandPath) {
return status
}
if (status.state === 'not_installed') {
return status
}
if (status.state === 'conflict') {
throw new Error(`Refusing to remove non-Orca command at ${status.commandPath}.`)
}
await this.run(this.distro as string, buildSafeRemoveCommand(status.commandPath))
return this.getStatus()
}
private async resolveReadyState(): Promise<
| { status: CliInstallStatus }
| {
distro: string
commandPath: string
bridgePath: string
launcherPath: string
pathConfigured: boolean
}
> {
if (this.platform !== 'win32') {
return {
status: this.unsupported(
'platform_not_supported',
'WSL CLI registration is only available on Windows.'
)
}
}
if (!this.distro) {
return {
status: this.unsupported('platform_not_supported', 'No WSL distribution is available.')
}
}
const hostStatus = await this.hostInstaller.getStatus()
if (!hostStatus.launcherPath) {
return {
status: this.unsupported(
hostStatus.unsupportedReason ?? 'launcher_missing',
hostStatus.detail ?? 'The Windows Orca CLI launcher is missing.'
)
}
}
const home = (await this.run(this.distro, 'printf %s "$HOME"')).trim()
if (!home.startsWith('/')) {
return {
status: this.unsupported('launcher_missing', 'Unable to resolve the WSL home directory.')
}
}
const interopReady =
(
await this.run(
this.distro,
'command -v powershell.exe >/dev/null 2>&1 && command -v wslpath >/dev/null 2>&1 && printf yes || printf no'
)
).trim() === 'yes'
if (!interopReady) {
return {
status: this.unsupported(
'launcher_missing',
'WSL Windows interop is unavailable; Orca cannot launch the Windows CLI from WSL.'
)
}
}
const pathDirectory = `${home}/.local/bin`
const commandPath = `${pathDirectory}/orca`
const pathConfigured =
(
await this.run(
this.distro,
`case ":$PATH:" in *:${quoteShell(pathDirectory)}:*) printf yes ;; *) printf no ;; esac`
)
).trim() === 'yes'
return {
distro: this.distro,
commandPath,
bridgePath: getBridgePathFromCommandPath(commandPath),
launcherPath: hostStatus.launcherPath,
pathConfigured
}
}
private async readCommandFile(
distro: string,
commandPath: string
): Promise<string | 'not_file' | null> {
const output = await this.run(
distro,
[
`if [ -L ${quoteShell(commandPath)} ]; then`,
' printf __ORCA_NOT_FILE__',
`elif [ ! -e ${quoteShell(commandPath)} ]; then`,
' printf __ORCA_MISSING__',
`elif [ ! -f ${quoteShell(commandPath)} ]; then`,
' printf __ORCA_NOT_FILE__',
'else',
` cat ${quoteShell(commandPath)}`,
'fi'
].join('\n')
)
if (output === '__ORCA_MISSING__') {
return null
}
if (output === '__ORCA_NOT_FILE__') {
return 'not_file'
}
return output
}
private buildStatus(args: {
distro: string
commandPath: string
launcherPath: string
state: CliInstallStatus['state']
currentTarget: string | null
pathConfigured: boolean
detail: string
}): CliInstallStatus {
return {
platform: 'linux',
commandName: 'orca',
commandPath: args.commandPath,
pathDirectory: args.commandPath.replace(/\/orca$/, ''),
pathConfigured: args.pathConfigured,
launcherPath: args.launcherPath,
installMethod: 'wrapper',
supported: true,
state: args.state,
currentTarget: args.currentTarget,
unsupportedReason: null,
detail:
args.state === 'installed' && !args.pathConfigured
? `${args.commandPath} is registered, but ${args.commandPath.replace(/\/orca$/, '')} is not on PATH in ${args.distro}.`
: args.detail
}
}
private unsupported(
unsupportedReason: NonNullable<CliInstallStatus['unsupportedReason']>,
detail: string
): CliInstallStatus {
return {
platform: 'linux',
commandName: 'orca',
commandPath: null,
pathDirectory: null,
pathConfigured: false,
launcherPath: null,
installMethod: null,
supported: false,
state: 'unsupported',
currentTarget: null,
unsupportedReason,
detail
}
}
private async run(distro: string, command: string): Promise<string> {
return this.wslRunner(distro, command)
}
}
async function runWslCommand(distro: string, command: string): Promise<string> {
const { stdout } = (await execFileAsync('wsl.exe', ['-d', distro, '--', 'bash', '-lc', command], {
encoding: 'utf8',
timeout: 5000
})) as { stdout: string }
return stdout
}
export const _internals = {
buildWslBridgeScript,
buildWslLauncher
}

View File

@ -0,0 +1,102 @@
const MANAGED_MARKER = '# Orca managed WSL CLI launcher'
const BRIDGE_MANAGED_MARKER = '# Orca managed WSL CLI PowerShell bridge'
export function buildWslLauncher(
windowsLauncherPath: string,
bridgePath = '${XDG_DATA_HOME:-$HOME/.local/share}/orca/orca-wsl-bridge.ps1'
): string {
const encodedTarget = Buffer.from(windowsLauncherPath, 'utf8').toString('base64')
return `#!/usr/bin/env bash
set -euo pipefail
${MANAGED_MARKER}
# ORCA_WIN_LAUNCHER_B64=${encodedTarget}
ORCA_WIN_LAUNCHER=${quoteShell(windowsLauncherPath)}
ORCA_BRIDGE_PS1=${quoteShell(bridgePath)}
ORCA_BRIDGE_PS1_WIN=$(wslpath -w "$ORCA_BRIDGE_PS1")
exec powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_PS1_WIN" "$ORCA_WIN_LAUNCHER" "$@"
`
}
export function buildWslBridgeScript(): string {
return `${BRIDGE_MANAGED_MARKER}
param(
[Parameter(Mandatory=$true)]
[string]$OrcaLauncher,
[Parameter(ValueFromRemainingArguments=$true)]
[string[]]$ForwardArgs
)
try {
& $OrcaLauncher @ForwardArgs
if (-not $?) {
exit 1
}
if ($null -eq $LASTEXITCODE) {
exit 0
}
exit $LASTEXITCODE
} catch {
Write-Error $_
exit 1
}
`
}
export function getBridgePathFromCommandPath(commandPath: string): string {
return `${commandPath.replace(/\/\.local\/bin\/orca$/, '/.local/share/orca')}/orca-wsl-bridge.ps1`
}
export function buildSafeReplaceGuard(path: string, managedMarker: string): string {
const quotedPath = quoteShell(path)
const quotedMarker = quoteShell(managedMarker)
return [
`if [ -L ${quotedPath} ]; then`,
' echo "__ORCA_CONFLICT__"',
' exit 23',
`elif [ -e ${quotedPath} ] && { [ ! -f ${quotedPath} ] || ! grep -Fq ${quotedMarker} ${quotedPath}; }; then`,
' echo "__ORCA_CONFLICT__"',
' exit 23',
'fi'
].join('\n')
}
export function buildSafeRemoveCommand(commandPath: string): string {
const bridgePath = getBridgePathFromCommandPath(commandPath)
return [
'set -euo pipefail',
buildSafeReplaceGuard(commandPath, MANAGED_MARKER),
buildSafeReplaceGuard(bridgePath, BRIDGE_MANAGED_MARKER),
`rm -f ${quoteShell(commandPath)} ${quoteShell(bridgePath)}`
].join('\n')
}
export function parseManagedLauncherTarget(content: string): string | null {
const encoded = content.match(/^# ORCA_WIN_LAUNCHER_B64=([A-Za-z0-9+/=]+)$/m)?.[1]
if (encoded) {
try {
return Buffer.from(encoded, 'base64').toString('utf8')
} catch {
return null
}
}
const legacyTarget = content.match(/^ORCA_WIN_LAUNCHER='((?:[^']|'"'"')*)'$/m)?.[1]
return legacyTarget ? legacyTarget.replaceAll(`'"'"'`, "'") : null
}
export function getPosixDirname(path: string): string {
return path.slice(0, path.lastIndexOf('/')) || '/'
}
export function getWslLauncherMarker(): string {
return MANAGED_MARKER
}
export function getWslBridgeMarker(): string {
return BRIDGE_MANAGED_MARKER
}
export function quoteShell(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`
}

View File

@ -3,10 +3,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import type * as LocalPtyUtils from '../providers/local-pty-utils'
const { spawnMock, isPwshAvailableMock } = vi.hoisted(() => ({
const { spawnMock, isPwshAvailableMock, validateWorkingDirectoryMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
isPwshAvailableMock: vi.fn()
isPwshAvailableMock: vi.fn(),
validateWorkingDirectoryMock: vi.fn((cwd: string) => {
if (cwd.includes('definitely-missing')) {
throw new Error(
`Working directory "${cwd}" does not exist. It may have been deleted or is on an unmounted volume.`
)
}
})
}))
vi.mock('node-pty', () => ({
@ -17,6 +25,14 @@ vi.mock('../pwsh', () => ({
isPwshAvailable: isPwshAvailableMock
}))
vi.mock('../providers/local-pty-utils', async (importOriginal) => {
const actual = await importOriginal<typeof LocalPtyUtils>()
return {
...actual,
validateWorkingDirectory: validateWorkingDirectoryMock
}
})
import { createPtySubprocess } from './pty-subprocess'
const ORCA_SHELL_WRAPPER_ENV = [
@ -57,6 +73,7 @@ describe('createPtySubprocess', () => {
beforeEach(() => {
spawnMock.mockReset()
isPwshAvailableMock.mockReset()
validateWorkingDirectoryMock.mockClear()
isPwshAvailableMock.mockReturnValue(false)
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
userDataPath = mkdtempSync(join(tmpdir(), 'daemon-pty-subprocess-test-'))
@ -741,6 +758,65 @@ describe('createPtySubprocess', () => {
)
})
it('launches WSL for WSL worktree cwd even when a stale Windows shell override is present', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
try {
createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24,
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo',
shellOverride: 'powershell.exe'
})
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--', 'bash', '-c', "cd '/home/jin/repo' && exec bash -l"],
expect.objectContaining({ cwd: expect.any(String) })
)
})
it('keeps daemon WSL split panes in their distro when cwd is already POSIX', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
try {
createPtySubprocess({
sessionId: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo@@deadbeef',
cols: 80,
rows: 24,
cwd: '/home/jin/repo/subdir',
shellOverride: 'wsl.exe'
})
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
expect(validateWorkingDirectoryMock).toHaveBeenCalledWith(
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo\\subdir'
)
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--', 'bash', '-c', "cd '/home/jin/repo/subdir' && exec bash -l"],
expect.objectContaining({ cwd: expect.any(String) })
)
})
// Why: node-pty's UnixTerminal.destroy() registers _socket.once('close', () =>
// this.kill('SIGHUP')), and the socket 'close' event can fire concurrently
// with onExit. If kill is not neutralized by the time close fires, SIGHUP

View File

@ -1,3 +1,5 @@
/* eslint-disable max-lines -- Why: daemon PTY spawning centralizes platform launch setup,
preflight validation, and lifecycle guards that must stay in one execution path. */
import * as pty from 'node-pty'
import { statSync } from 'fs'
import { win32 as pathWin32 } from 'path'
@ -18,6 +20,8 @@ import { resolveWindowsShellLaunchArgs } from '../providers/windows-shell-args'
import { resolveEffectiveWindowsPowerShell } from '../providers/windows-powershell'
import { isPwshAvailable } from '../pwsh'
import { removeInheritedNoColor } from '../pty/terminal-color-env'
import { parseWslPath } from '../wsl'
import { getWslContextFromSessionId } from './wsl-session-context'
const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const
@ -185,7 +189,14 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
// setting, relayed by main) takes priority over env.COMSPEC — otherwise
// Windows always resolves to cmd.exe (COMSPEC) or PowerShell by fallback,
// no matter which shell the user actually picked.
let shellPath = opts.shellOverride || resolvePtyShellPath(env)
const cwdWslInfo = process.platform === 'win32' ? parseWslPath(opts.cwd ?? '') : null
const sessionWslContext =
process.platform === 'win32' ? getWslContextFromSessionId(opts.sessionId) : undefined
// Why: WSL worktree cwd is the repo's execution environment. Older persisted
// tabs can carry a PowerShell/cmd shellOverride; ignore it so reconnects and
// daemon-backed terminals enter the WSL distro just like LocalPtyProvider.
let shellPath =
cwdWslInfo || sessionWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env)
let shellArgs: string[]
let spawnCwd = opts.cwd || getDefaultCwd()
let validationCwd = spawnCwd
@ -217,7 +228,12 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
// Reuse the same shared launch-args helper after resolving the effective
// PowerShell executable so daemon-backed terminals preserve parity with the
// in-process PTY path.
const resolved = resolveWindowsShellLaunchArgs(shellPath, spawnCwd, getDefaultCwd())
const resolved = resolveWindowsShellLaunchArgs(
shellPath,
spawnCwd,
getDefaultCwd(),
sessionWslContext
)
shellArgs = resolved.shellArgs
spawnCwd = resolved.effectiveCwd
validationCwd = resolved.validationCwd

View File

@ -0,0 +1,14 @@
import { splitWorktreeId } from '../../shared/worktree-id'
import { parseWslPath } from '../wsl'
import { parsePtySessionId } from './pty-session-id'
export type WslSessionContext = {
distro: string
}
export function getWslContextFromSessionId(sessionId: string): WslSessionContext | undefined {
const worktreeId = parsePtySessionId(sessionId).worktreeId
const worktreePath = worktreeId ? splitWorktreeId(worktreeId)?.worktreePath : undefined
const wslInfo = worktreePath ? parseWslPath(worktreePath) : null
return wslInfo ? { distro: wslInfo.distro } : undefined
}

View File

@ -1,6 +1,7 @@
import { ipcMain } from 'electron'
import type { CliInstallStatus } from '../../shared/cli-install-types'
import { CliInstaller } from '../cli/cli-installer'
import { WslCliInstaller } from '../cli/wsl-cli-installer'
export function registerCliHandlers(): void {
ipcMain.handle('cli:getInstallStatus', async (): Promise<CliInstallStatus> => {
@ -14,4 +15,16 @@ export function registerCliHandlers(): void {
ipcMain.handle('cli:remove', async (): Promise<CliInstallStatus> => {
return new CliInstaller().remove()
})
ipcMain.handle('cli:getWslInstallStatus', async (): Promise<CliInstallStatus> => {
return new WslCliInstaller().getStatus()
})
ipcMain.handle('cli:installWsl', async (): Promise<CliInstallStatus> => {
return new WslCliInstaller().install()
})
ipcMain.handle('cli:removeWsl', async (): Promise<CliInstallStatus> => {
return new WslCliInstaller().remove()
})
}

View File

@ -38,7 +38,20 @@ vi.mock('node-pty', () => ({
}))
vi.mock('../wsl', () => ({
parseWslPath: () => null
parseWslPath: (path: string) => {
const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/)
if (!match) {
return null
}
return {
distro: match[1],
linuxPath: (match[2] || '').replace(/\\/g, '/') || '/'
}
},
toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'),
toWindowsWslPath: (path: string, distro: string) =>
`\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`,
isWslAvailable: () => true
}))
import { LocalPtyProvider } from './local-pty-provider'
@ -56,8 +69,11 @@ describe('LocalPtyProvider', () => {
}
let exitCb: ((info: { exitCode: number }) => void) | undefined
let origShell: string | undefined
let origPlatform: PropertyDescriptor | undefined
beforeEach(() => {
origPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
origShell = process.env.SHELL
process.env.SHELL = '/bin/zsh'
@ -87,6 +103,9 @@ describe('LocalPtyProvider', () => {
})
afterEach(() => {
if (origPlatform) {
Object.defineProperty(process, 'platform', origPlatform)
}
if (origShell === undefined) {
delete process.env.SHELL
} else {
@ -244,6 +263,31 @@ describe('LocalPtyProvider', () => {
expect.objectContaining({ cwd: 'D:\\Users\\orca' })
)
})
it('launches POSIX cwd split panes through WSL when worktree context is WSL', async () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
try {
await provider.spawn({
cols: 80,
rows: 24,
cwd: '/home/jin/repo/subdir',
shellOverride: 'powershell.exe',
worktreeId: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
})
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--', 'bash', '-c', "cd '/home/jin/repo/subdir' && exec bash -l"],
expect.objectContaining({ cwd: expect.any(String) })
)
})
})
describe('write', () => {

View File

@ -10,6 +10,7 @@ import { resolveProcessCwd } from './process-cwd'
import { existsSync } from 'fs'
import * as pty from 'node-pty'
import { parseWslPath, isWslAvailable } from '../wsl'
import { splitWorktreeId } from '../../shared/worktree-id'
import {
injectHistoryEnv,
updateHistFileForFallback,
@ -89,6 +90,14 @@ function disposePtyListeners(id: string): void {
}
}
function getWslContextFromWorktreeId(
worktreeId: string | undefined
): { distro: string } | undefined {
const worktreePath = worktreeId ? splitWorktreeId(worktreeId)?.worktreePath : undefined
const wslInfo = worktreePath ? parseWslPath(worktreePath) : null
return wslInfo ? { distro: wslInfo.distro } : undefined
}
function clearPtyState(id: string): void {
disposePtyListeners(id)
ptyProcesses.delete(id)
@ -163,6 +172,8 @@ export class LocalPtyProvider implements IPtyProvider {
const defaultCwd = getDefaultCwd()
const cwd = args.cwd || defaultCwd
const wslInfo = process.platform === 'win32' ? parseWslPath(cwd) : null
const worktreeWslContext =
process.platform === 'win32' ? getWslContextFromWorktreeId(args.worktreeId) : undefined
let shellPath: string
let shellArgs: string[]
@ -182,11 +193,12 @@ export class LocalPtyProvider implements IPtyProvider {
// Why: shellOverride lets a single tab open in a different shell than the
// persisted default (e.g. "New WSL terminal" from the "+" submenu) without
// changing the user's setting. It takes priority over the setting.
const shellFamily =
const requestedShellFamily =
args.shellOverride ||
this.opts.getWindowsShell?.() ||
process.env.COMSPEC ||
'powershell.exe'
const shellFamily = worktreeWslContext ? 'wsl.exe' : requestedShellFamily
const normalizedShellFamily = pathWin32.basename(shellFamily).toLowerCase()
// Why: shell selection can arrive either as a canonical setting value
// ('powershell.exe') or as a concrete PowerShell executable path from a
@ -215,7 +227,7 @@ export class LocalPtyProvider implements IPtyProvider {
// same shellArgs for the same (shell, cwd) pair. The helper keeps CJK
// UTF-8 setup (chcp 65001), PowerShell $PROFILE dot-sourcing, and the
// wsl.exe /mnt/<drive> cwd translation in one place.
const resolved = resolveWindowsShellLaunchArgs(shellPath, cwd, defaultCwd)
const resolved = resolveWindowsShellLaunchArgs(shellPath, cwd, defaultCwd, worktreeWslContext)
shellArgs = resolved.shellArgs
effectiveCwd = resolved.effectiveCwd
validationCwd = resolved.validationCwd

View File

@ -119,6 +119,26 @@ describe('resolveWindowsShellLaunchArgs', () => {
}
})
it('keeps POSIX cwd inside the worktree distro when WSL context is provided', () => {
const result = resolveWindowsShellLaunchArgs(
'wsl.exe',
'/home/alice/repo/subdir',
'C:\\Users\\alice',
{ distro: 'Ubuntu' }
)
expect(result.shellArgs).toEqual([
'-d',
'Ubuntu',
'--',
'bash',
'-c',
"cd '/home/alice/repo/subdir' && exec bash -l"
])
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
expect(result.validationCwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo\\subdir')
})
it('falls back to empty args + same cwd for unknown shells', () => {
const result = resolveWindowsShellLaunchArgs(
'C:\\tools\\fish.exe',

View File

@ -1,5 +1,5 @@
import { win32 as pathWin32 } from 'path'
import { parseWslPath, toLinuxPath } from '../wsl'
import { parseWslPath, toLinuxPath, toWindowsWslPath } from '../wsl'
import {
encodePowerShellCommand,
getPowerShellOsc133Bootstrap
@ -25,6 +25,16 @@ export type WindowsShellLaunchArgs = {
validationCwd: string
}
export type WindowsShellWslContext = {
distro: string
}
function buildWslShellArgs(linuxCwd: string, distro?: string): string[] {
const escapedLinuxCwd = linuxCwd.replace(/'/g, "'\\''")
const shellArgs = ['--', 'bash', '-c', `cd '${escapedLinuxCwd}' && exec bash -l`]
return distro ? ['-d', distro, ...shellArgs] : shellArgs
}
/** Build the argv + effective cwd for a Windows shell launch.
*
* - cmd.exe: `/K chcp 65001 > nul` so multi-byte CJK output renders correctly.
@ -37,7 +47,8 @@ export type WindowsShellLaunchArgs = {
export function resolveWindowsShellLaunchArgs(
shellPath: string,
cwd: string,
defaultCwd: string
defaultCwd: string,
wslContext?: WindowsShellWslContext
): WindowsShellLaunchArgs {
const shellBasename = pathWin32.basename(shellPath).toLowerCase()
@ -67,25 +78,23 @@ export function resolveWindowsShellLaunchArgs(
if (shellBasename === 'wsl.exe') {
const wslInfo = parseWslPath(cwd)
if (wslInfo) {
const escapedLinuxCwd = wslInfo.linuxPath.replace(/'/g, "'\\''")
return {
shellArgs: [
'-d',
wslInfo.distro,
'--',
'bash',
'-c',
`cd '${escapedLinuxCwd}' && exec bash -l`
],
shellArgs: buildWslShellArgs(wslInfo.linuxPath, wslInfo.distro),
effectiveCwd: defaultCwd,
validationCwd: cwd
}
}
if (wslContext && cwd.startsWith('/')) {
return {
shellArgs: buildWslShellArgs(cwd, wslContext.distro),
effectiveCwd: defaultCwd,
validationCwd: toWindowsWslPath(cwd, wslContext.distro)
}
}
const driveMatch = cwd.replace(/\\/g, '/').match(/^([A-Za-z]):\/?(.*)$/)
const linuxCwd = driveMatch ? toLinuxPath(cwd) : '/mnt/c'
const escapedLinuxCwd = linuxCwd.replace(/'/g, "'\\''")
return {
shellArgs: ['--', 'bash', '-c', `cd '${escapedLinuxCwd}' && exec bash -l`],
shellArgs: buildWslShellArgs(linuxCwd),
effectiveCwd: defaultCwd,
validationCwd: cwd
}

View File

@ -1,4 +1,5 @@
import { execFileSync } from 'child_process'
import { parseWslUncPath } from '../shared/wsl-paths'
export type WslPathInfo = {
distro: string
@ -19,19 +20,7 @@ export function parseWslPath(windowsPath: string): WslPathInfo | null {
return null
}
// Normalize backslashes to forward slashes for uniform matching
const normalized = windowsPath.replace(/\\/g, '/')
// Match //wsl.localhost/Distro/... or //wsl$/Distro/...
const match = normalized.match(/^\/\/(wsl\.localhost|wsl\$)\/([^/]+)(\/.*)?$/)
if (!match) {
return null
}
return {
distro: match[2],
linuxPath: match[3] || '/'
}
return parseWslUncPath(windowsPath)
}
export function isWslPath(path: string): boolean {

View File

@ -1209,6 +1209,9 @@ export type PreloadApi = {
getInstallStatus: () => Promise<CliInstallStatus>
install: () => Promise<CliInstallStatus>
remove: () => Promise<CliInstallStatus>
getWslInstallStatus: () => Promise<CliInstallStatus>
installWsl: () => Promise<CliInstallStatus>
removeWsl: () => Promise<CliInstallStatus>
}
agentHooks: {
claudeStatus: () => Promise<AgentHookInstallStatus>

View File

@ -1245,7 +1245,11 @@ const api = {
cli: {
getInstallStatus: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:getInstallStatus'),
install: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:install'),
remove: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:remove')
remove: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:remove'),
getWslInstallStatus: (): Promise<CliInstallStatus> =>
ipcRenderer.invoke('cli:getWslInstallStatus'),
installWsl: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:installWsl'),
removeWsl: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:removeWsl')
},
agentHooks: {

View File

@ -176,14 +176,6 @@ function Terminal(): React.JSX.Element | null {
? (browserTabsByWorktree[activeWorktreeId] ?? []).map((tab) => tab.id).join(',')
: ''
const [wslAvailable, setWslAvailable] = useState(false)
useEffect(() => {
// Why: wsl:isAvailable is synchronous on the main-process side but we
// call it asynchronously so the renderer doesn't block on startup. The
// result only gates UI options, so a brief false→true transition is fine.
void window.api.wsl.isAvailable().then(setWslAvailable)
}, [])
// Save confirmation dialog state
const [saveDialogFileId, setSaveDialogFileId] = useState<string | null>(null)
const saveDialogFile = saveDialogFileId ? openFiles.find((f) => f.id === saveDialogFileId) : null
@ -1338,7 +1330,6 @@ function Terminal(): React.JSX.Element | null {
onNewTerminalWithShell={handleNewTab}
onNewBrowserTab={handleNewBrowserTab}
onNewFileTab={handleNewFile}
wslAvailable={wslAvailable}
onSetCustomTitle={setTabCustomTitle}
onSetTabColor={setTabColor}
expandedPaneByTabId={expandedPaneByTabId}

View File

@ -14,6 +14,7 @@ import {
} from '../ui/dialog'
import { Label } from '../ui/label'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import { WslCliRegistration } from './WslCliRegistration'
type CliSectionProps = {
currentPlatform: string
@ -246,6 +247,8 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
) : null}
</div>
<WslCliRegistration currentPlatform={currentPlatform} />
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { shouldCommitOpenInApplicationsDraft } from './GeneralPane'
import { getDesktopPlatformFromUserAgent, shouldCommitOpenInApplicationsDraft } from './GeneralPane'
describe('GeneralPane open-in application drafts', () => {
it('does not commit rows until both label and command are present', () => {
@ -30,3 +30,17 @@ describe('GeneralPane open-in application drafts', () => {
).toBe(true)
})
})
describe('GeneralPane desktop platform detection', () => {
it('keeps Windows available for Windows-only CLI settings', () => {
expect(
getDesktopPlatformFromUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
)
).toBe('win32')
expect(getDesktopPlatformFromUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')).toBe(
'darwin'
)
expect(getDesktopPlatformFromUserAgent('Mozilla/5.0 (X11; Linux x86_64)')).toBe('other')
})
})

View File

@ -55,6 +55,16 @@ export function shouldCommitOpenInApplicationsDraft(applications: OpenInApplicat
})
}
export function getDesktopPlatformFromUserAgent(userAgent: string): 'darwin' | 'win32' | 'other' {
if (userAgent.includes('Mac')) {
return 'darwin'
}
if (userAgent.includes('Windows')) {
return 'win32'
}
return 'other'
}
export { GENERAL_PANE_SEARCH_ENTRIES }
type GeneralPaneProps = {
@ -652,7 +662,7 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
matchesSettingsSearch(searchQuery, GENERAL_CLI_SEARCH_ENTRIES) ? (
<CliSection
key="cli"
currentPlatform={navigator.userAgent.includes('Mac') ? 'darwin' : 'other'}
currentPlatform={getDesktopPlatformFromUserAgent(navigator.userAgent)}
/>
) : null,
matchesSettingsSearch(searchQuery, GENERAL_CACHE_TIMER_SEARCH_ENTRIES) ? (

View File

@ -79,6 +79,7 @@ import { SettingsSidebar } from './SettingsSidebar'
import { ActiveSettingsSectionProvider, SettingsSection } from './SettingsSection'
import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search'
import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client'
import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities'
import {
deriveNeededRepoIds,
deriveNeededSectionIds,
@ -244,8 +245,6 @@ function Settings(): React.JSX.Element {
// the import trigger as a headerAction. The modal itself still lives inside
// TerminalPane, driven by this shared state.
const ghostty = useGhosttyImport(updateSettings, settings)
const [wslAvailable, setWslAvailable] = useState(false)
const [pwshAvailable, setPwshAvailable] = useState(false)
const [fontSuggestions, setFontSuggestions] = useState<string[]>(
Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...getFallbackTerminalFonts()]))
)
@ -265,7 +264,6 @@ function Settings(): React.JSX.Element {
const contentScrollRef = useRef<HTMLDivElement | null>(null)
const searchInputRef = useRef<HTMLInputElement | null>(null)
const terminalFontsLoadedRef = useRef(false)
const terminalCapabilitiesLoadedRef = useRef(false)
const pendingNavSectionRef = useRef<string | null>(null)
const pendingScrollTargetRef = useRef<string | null>(null)
const repoHooksRequestSeqRef = useRef(0)
@ -678,6 +676,9 @@ function Settings(): React.JSX.Element {
}),
[activeSectionId, mountedSectionIds, navSections, settingsSearchQuery, visibleSectionIds]
)
const windowsTerminalCapabilities = useWindowsTerminalCapabilities(
isWindows && neededSectionIds.has('terminal')
)
useEffect(() => {
setMountedSectionIds((previous) => {
@ -722,34 +723,6 @@ function Settings(): React.JSX.Element {
}
}, [neededSectionIds])
useEffect(() => {
if (!isWindows) {
setWslAvailable(false)
setPwshAvailable(false)
terminalCapabilitiesLoadedRef.current = true
return
}
if (!neededSectionIds.has('terminal') || terminalCapabilitiesLoadedRef.current) {
return
}
let stale = false
terminalCapabilitiesLoadedRef.current = true
void window.api.wsl.isAvailable().then((available) => {
if (!stale) {
setWslAvailable(available)
}
})
void window.api.pwsh.isAvailable().then((available) => {
if (!stale) {
setPwshAvailable(available)
}
})
return () => {
stale = true
}
}, [isWindows, neededSectionIds])
const neededRepoIds = useMemo(
() => deriveNeededRepoIds(repos, neededSectionIds),
[neededSectionIds, repos]
@ -1088,8 +1061,8 @@ function Settings(): React.JSX.Element {
scrollbackMode={scrollbackMode}
setScrollbackMode={setScrollbackMode}
ghostty={ghostty}
wslAvailable={wslAvailable}
pwshAvailable={pwshAvailable}
wslAvailable={windowsTerminalCapabilities.wslAvailable}
pwshAvailable={windowsTerminalCapabilities.pwshAvailable}
/>
) : null}
</SettingsSection>

View File

@ -211,4 +211,46 @@ describe('TerminalPane PowerShell version setting', () => {
expect(link).not.toBeNull()
expect(link?.props.href).toBe('https://github.com/PowerShell/PowerShell/releases/latest')
})
it('shows WSL as a Windows default shell option when available', () => {
const element = TerminalPane({
settings: {
terminalScrollbackBytes: 10_000_000,
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'auto',
terminalWordSeparator: ''
} as never,
updateSettings: () => {},
systemPrefersDark: true,
terminalFontSuggestions: [],
scrollbackMode: 'preset',
setScrollbackMode: () => {},
ghostty: ghosttyMock,
wslAvailable: true,
pwshAvailable: false
})
expect(collectText(element)).toContain('WSL')
})
it('hides WSL as a Windows default shell option when unavailable', () => {
const element = TerminalPane({
settings: {
terminalScrollbackBytes: 10_000_000,
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'auto',
terminalWordSeparator: ''
} as never,
updateSettings: () => {},
systemPrefersDark: true,
terminalFontSuggestions: [],
scrollbackMode: 'preset',
setScrollbackMode: () => {},
ghostty: ghosttyMock,
wslAvailable: false,
pwshAvailable: false
})
expect(collectText(element)).not.toContain('WSL')
})
})

View File

@ -0,0 +1,190 @@
import { useEffect, useState } from 'react'
import { RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities'
import { Button } from '../ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '../ui/dialog'
import { Label } from '../ui/label'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
type WslCliRegistrationProps = {
currentPlatform: string
}
export function WslCliRegistration({
currentPlatform
}: WslCliRegistrationProps): React.JSX.Element | null {
const [status, setStatus] = useState<CliInstallStatus | null>(null)
const [loading, setLoading] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
const [busyAction, setBusyAction] = useState<'install' | 'remove' | null>(null)
const { wslAvailable } = useWindowsTerminalCapabilities(currentPlatform === 'win32')
const showWslCli = currentPlatform === 'win32' && wslAvailable
const refreshStatus = async (): Promise<void> => {
setLoading(true)
try {
setStatus(await window.api.cli.getWslInstallStatus())
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to load WSL CLI status.')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (showWslCli) {
void refreshStatus()
}
}, [showWslCli])
if (!showWslCli) {
return null
}
const isEnabled = status?.state === 'installed'
const isSupported = status?.supported ?? false
const handleInstall = async (): Promise<void> => {
setBusyAction('install')
try {
const next = await window.api.cli.installWsl()
setStatus(next)
setDialogOpen(false)
toast.success('Registered `orca` in WSL.')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to register `orca` in WSL.')
} finally {
setBusyAction(null)
}
}
const handleRemove = async (): Promise<void> => {
setBusyAction('remove')
try {
const next = await window.api.cli.removeWsl()
setStatus(next)
setDialogOpen(false)
toast.success('Removed `orca` from WSL.')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to remove `orca` from WSL.')
} finally {
setBusyAction(null)
}
}
return (
<>
<div className="space-y-3 rounded-xl border border-border/60 bg-card/50 p-4">
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label>WSL shell command</Label>
<p className="text-xs text-muted-foreground">
{loading
? 'Checking WSL CLI registration...'
: (status?.detail ?? 'Register `orca` in ~/.local/bin inside WSL.')}
</p>
</div>
<div className="flex items-center gap-2">
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => void refreshStatus()}
disabled={loading || busyAction !== null}
aria-label="Refresh WSL CLI status"
>
<RefreshCw className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Refresh
</TooltipContent>
</Tooltip>
</TooltipProvider>
<button
role="switch"
aria-checked={isEnabled}
disabled={loading || !isSupported || busyAction !== null}
onClick={() => setDialogOpen(true)}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent transition-colors ${
isEnabled ? 'bg-foreground' : 'bg-muted-foreground/30'
} ${loading || !isSupported || busyAction !== null ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
isEnabled ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
</div>
{status?.commandPath ? (
<p className="text-xs text-muted-foreground">
Command path:{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{status.commandPath}</code>
</p>
) : null}
{status?.state === 'stale' && status.currentTarget ? (
<p className="text-xs text-amber-600 dark:text-amber-400">
Existing launcher target: <code>{status.currentTarget}</code>
</p>
) : null}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isEnabled ? 'Remove `orca` from WSL?' : 'Register `orca` in WSL?'}
</DialogTitle>
<DialogDescription>
{isEnabled
? 'This removes the WSL shell command. Orca itself remains installed on Windows.'
: `Orca will register ${status?.commandPath ?? '`orca`'} so the command works from WSL terminals.`}
</DialogDescription>
</DialogHeader>
{status?.commandPath ? (
<p className="text-xs text-muted-foreground">
Target path:{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px]">{status.commandPath}</code>
</p>
) : null}
<DialogFooter>
<Button
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={busyAction !== null}
>
Cancel
</Button>
<Button
onClick={() => void (isEnabled ? handleRemove() : handleInstall())}
disabled={busyAction !== null || !isSupported}
>
{busyAction === 'remove'
? 'Removing...'
: busyAction === 'install'
? 'Registering...'
: isEnabled
? 'Remove'
: 'Register'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -181,7 +181,6 @@ async function renderTabBar(props: Record<string, unknown>): Promise<unknown> {
onSetCustomTitle: () => {},
onSetTabColor: () => {},
onTogglePaneExpand: () => {},
wslAvailable: false,
...props
})
}

View File

@ -26,6 +26,7 @@ import { getEditorDisplayLabel } from '@/components/editor/editor-labels'
import { ShellIcon } from './shell-icons'
import { resolveWindowsShellLaunchTarget } from './windows-shell-launch'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities'
import {
DropdownMenu,
DropdownMenuContent,
@ -58,9 +59,6 @@ type TabBarProps = {
terminalOnly?: boolean
showAgentLaunchItems?: boolean
onNewFileTab?: () => void
/** Whether WSL is installed on this Windows machine. When true, the "+"
* dropdown shows a WSL option under the terminal submenu. */
wslAvailable?: boolean
onSetCustomTitle: (tabId: string, title: string | null) => void
onSetTabColor: (tabId: string, color: string | null) => void
onTogglePaneExpand: (tabId: string) => void
@ -142,8 +140,7 @@ function TabBarInner({
onPinFile,
tabBarOrder,
onCreateSplitGroup,
hoveredTabInsertion,
wslAvailable
hoveredTabInsertion
}: TabBarProps): React.JSX.Element {
const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree)
const defaultWindowsShell = useAppStore(
@ -152,15 +149,7 @@ function TabBarInner({
const defaultWindowsPowerShellImplementation = useAppStore(
(s) => s.settings?.terminalWindowsPowerShellImplementation ?? 'auto'
)
const [pwshAvailable, setPwshAvailable] = useState(false)
useEffect(() => {
if (!isWindows) {
setPwshAvailable(false)
return
}
void window.api.pwsh.isAvailable().then(setPwshAvailable)
}, [])
const windowsTerminalCapabilities = useWindowsTerminalCapabilities(isWindows)
const resolvedGroupId = groupId ?? worktreeId
const statusByRelativePath = useMemo(
@ -497,7 +486,9 @@ function TabBarInner({
}[] = [
{ label: 'PowerShell', shell: 'powershell.exe' },
{ label: 'CMD Prompt', shell: 'cmd.exe' },
...(wslAvailable ? ([{ label: 'WSL', shell: 'wsl.exe' }] as const) : [])
...(windowsTerminalCapabilities.wslAvailable
? ([{ label: 'WSL', shell: 'wsl.exe' }] as const)
: [])
]
const defaultEntry =
allShells.find((s) => s.shell === defaultWindowsShell) ?? allShells[0]
@ -520,7 +511,7 @@ function TabBarInner({
resolveWindowsShellLaunchTarget(
entry.shell,
defaultWindowsPowerShellImplementation,
pwshAvailable
windowsTerminalCapabilities.pwshAvailable
)
)
}}

View File

@ -28,7 +28,8 @@ vi.mock('react', async () => {
useLayoutEffect: () => {},
useMemo: <T>(factory: () => T) => factory(),
useRef: <T>(current: T) => ({ current }),
useState: <T>(initial: T) => [initial, vi.fn()] as const
useState: <T>(initial: T | (() => T)) =>
[typeof initial === 'function' ? (initial as () => T)() : initial, vi.fn()] as const
}
})
@ -205,6 +206,15 @@ describe('TabBar PowerShell launch wiring', () => {
})
it('passes pwsh.exe when the PowerShell menu item uses the PowerShell 7+ implementation', async () => {
vi.stubGlobal('window', {
api: {
wsl: { isAvailable: vi.fn().mockResolvedValue(false) },
pwsh: { isAvailable: vi.fn().mockResolvedValue(true) }
}
})
const capabilities = await import('@/lib/windows-terminal-capabilities')
await capabilities.loadWindowsTerminalCapabilities()
const tabBarModule = await import('./TabBar')
const candidate = tabBarModule.default ?? tabBarModule
const TabBar =
@ -230,8 +240,7 @@ describe('TabBar PowerShell launch wiring', () => {
onNewBrowserTab: () => {},
onSetCustomTitle: () => {},
onSetTabColor: () => {},
onTogglePaneExpand: () => {},
wslAvailable: false
onTogglePaneExpand: () => {}
})
const item = findDropdownMenuItemByText(expandNode(element), 'New Terminal: PowerShell')
@ -242,4 +251,44 @@ describe('TabBar PowerShell launch wiring', () => {
expect(onNewTerminalWithShell).toHaveBeenCalledWith('pwsh.exe')
})
it('shows the WSL terminal row when shared Windows capabilities report WSL', async () => {
vi.stubGlobal('window', {
api: {
wsl: { isAvailable: vi.fn().mockResolvedValue(true) },
pwsh: { isAvailable: vi.fn().mockResolvedValue(false) }
}
})
const capabilities = await import('@/lib/windows-terminal-capabilities')
await capabilities.loadWindowsTerminalCapabilities()
const tabBarModule = await import('./TabBar')
const candidate = tabBarModule.default ?? tabBarModule
const TabBar =
typeof candidate === 'function'
? candidate
: typeof (candidate as { type?: unknown }).type === 'function'
? (candidate as { type: (props: Record<string, unknown>) => unknown }).type
: null
expect(TabBar).not.toBeNull()
const element = TabBar!({
tabs: [],
activeTabId: null,
worktreeId: 'wt-1',
expandedPaneByTabId: {},
onActivate: () => {},
onClose: () => {},
onCloseOthers: () => {},
onCloseToRight: () => {},
onNewTerminalTab: () => {},
onNewTerminalWithShell: () => {},
onNewBrowserTab: () => {},
onSetCustomTitle: () => {},
onSetTabColor: () => {},
onTogglePaneExpand: () => {}
})
expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: WSL')).not.toBeNull()
})
})

View File

@ -1,4 +1,4 @@
import { lazy, Suspense, useEffect, useMemo, useState } from 'react'
import { lazy, Suspense, useMemo } from 'react'
import { useDroppable } from '@dnd-kit/core'
import { Columns2, Ellipsis, Rows2, X } from 'lucide-react'
import { useAppStore } from '../../store'
@ -50,11 +50,6 @@ export default function TabGroupPanel({
const rightSidebarOpen = useAppStore((state) => state.rightSidebarOpen)
const sidebarOpen = useAppStore((state) => state.sidebarOpen)
const [wslAvailable, setWslAvailable] = useState(false)
useEffect(() => {
void window.api.wsl.isAvailable().then(setWslAvailable)
}, [])
const model = useTabGroupWorkspaceModel({ groupId, worktreeId })
const { activeTab, browserItems, commands, editorItems, tabBarOrder, terminalTabs } = model
const { setNodeRef: setBodyDropRef } = useDroppable({
@ -116,7 +111,6 @@ export default function TabGroupPanel({
}}
onNewTerminalTab={commands.newTerminalTab}
onNewTerminalWithShell={commands.newTerminalWithShell}
wslAvailable={wslAvailable}
onNewBrowserTab={commands.newBrowserTab}
onNewFileTab={commands.newFileTab}
onSetCustomTitle={commands.setTabCustomTitle}

View File

@ -32,7 +32,6 @@ type TerminalShellProps = {
onNewTerminalWithShell?: (shell: string) => void
onNewBrowserTab: () => void
onNewFileTab?: () => void
wslAvailable?: boolean
onSetCustomTitle: (tabId: string, title: string | null) => void
onSetTabColor: (tabId: string, color: string | null) => void
onTogglePaneExpand: (tabId: string) => void
@ -70,7 +69,6 @@ export function TerminalShell({
onNewTerminalWithShell,
onNewBrowserTab,
onNewFileTab,
wslAvailable,
onSetCustomTitle,
onSetTabColor,
onTogglePaneExpand,
@ -110,7 +108,6 @@ export function TerminalShell({
onNewTerminalWithShell={onNewTerminalWithShell}
onNewBrowserTab={onNewBrowserTab}
onNewFileTab={onNewFileTab}
wslAvailable={wslAvailable}
onSetCustomTitle={onSetCustomTitle}
onSetTabColor={onSetTabColor}
expandedPaneByTabId={expandedPaneByTabId}

View File

@ -0,0 +1,24 @@
import type { AppState } from '@/store/types'
import { parseWslUncPath } from '../../../shared/wsl-paths'
export type LocalPreflightContext = { wslDistro?: string | null } | undefined
export function getWslDistroFromPath(path?: string | null): string | null {
return path ? (parseWslUncPath(path)?.distro ?? null) : null
}
export function getLocalPreflightContext(state: AppState): LocalPreflightContext {
const activeWorktree = state.activeWorktreeId
? Object.values(state.worktreesByRepo ?? {})
.flat()
.find((worktree) => worktree.id === state.activeWorktreeId)
: null
const activePath =
activeWorktree?.path ?? (state.repos ?? []).find((repo) => repo.id === state.activeRepoId)?.path
const wslDistro = getWslDistroFromPath(activePath)
return wslDistro ? { wslDistro } : undefined
}
export function localPreflightContextKey(context: LocalPreflightContext): string {
return context?.wslDistro ? `wsl:${context.wslDistro}` : 'host'
}

View File

@ -0,0 +1,118 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
getCachedWindowsTerminalCapabilities,
loadWindowsTerminalCapabilities,
refreshWindowsTerminalCapabilities,
resetWindowsTerminalCapabilitiesForTests
} from './windows-terminal-capabilities'
function stubTerminalCapabilityApi(args: { wslAvailable: boolean; pwshAvailable: boolean }): {
wslIsAvailable: ReturnType<typeof vi.fn>
pwshIsAvailable: ReturnType<typeof vi.fn>
} {
const wslIsAvailable = vi.fn().mockResolvedValue(args.wslAvailable)
const pwshIsAvailable = vi.fn().mockResolvedValue(args.pwshAvailable)
vi.stubGlobal('window', {
api: {
wsl: { isAvailable: wslIsAvailable },
pwsh: { isAvailable: pwshIsAvailable }
}
})
return { wslIsAvailable, pwshIsAvailable }
}
describe('windows terminal capabilities', () => {
afterEach(() => {
resetWindowsTerminalCapabilitiesForTests()
vi.unstubAllGlobals()
})
it('shares WSL and PowerShell availability between terminal UI consumers', async () => {
const { wslIsAvailable, pwshIsAvailable } = stubTerminalCapabilityApi({
wslAvailable: true,
pwshAvailable: true
})
expect(getCachedWindowsTerminalCapabilities()).toEqual({
wslAvailable: false,
pwshAvailable: false
})
await expect(loadWindowsTerminalCapabilities()).resolves.toEqual({
wslAvailable: true,
pwshAvailable: true
})
expect(getCachedWindowsTerminalCapabilities()).toEqual({
wslAvailable: true,
pwshAvailable: true
})
await loadWindowsTerminalCapabilities()
expect(wslIsAvailable).toHaveBeenCalledTimes(1)
expect(pwshIsAvailable).toHaveBeenCalledTimes(1)
})
it('keeps WSL available when the PowerShell version probe fails', async () => {
const wslIsAvailable = vi.fn().mockResolvedValue(true)
const pwshIsAvailable = vi.fn().mockRejectedValue(new Error('pwsh probe failed'))
vi.stubGlobal('window', {
api: {
wsl: { isAvailable: wslIsAvailable },
pwsh: { isAvailable: pwshIsAvailable }
}
})
await expect(loadWindowsTerminalCapabilities()).resolves.toEqual({
wslAvailable: true,
pwshAvailable: false
})
})
it('can refresh cached capabilities when WSL availability changes', async () => {
const wslIsAvailable = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true)
const pwshIsAvailable = vi.fn().mockResolvedValue(false)
vi.stubGlobal('window', {
api: {
wsl: { isAvailable: wslIsAvailable },
pwsh: { isAvailable: pwshIsAvailable }
}
})
await expect(loadWindowsTerminalCapabilities()).resolves.toMatchObject({
wslAvailable: false
})
await expect(loadWindowsTerminalCapabilities()).resolves.toMatchObject({
wslAvailable: false
})
await expect(refreshWindowsTerminalCapabilities()).resolves.toMatchObject({
wslAvailable: true
})
expect(wslIsAvailable).toHaveBeenCalledTimes(2)
})
it('re-probes when the capability cache expires', async () => {
const wslIsAvailable = vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false)
const pwshIsAvailable = vi.fn().mockResolvedValue(false)
vi.stubGlobal('window', {
api: {
wsl: { isAvailable: wslIsAvailable },
pwsh: { isAvailable: pwshIsAvailable }
}
})
await expect(loadWindowsTerminalCapabilities({ now: 1_000 })).resolves.toMatchObject({
wslAvailable: true
})
await expect(loadWindowsTerminalCapabilities({ now: 20_000 })).resolves.toMatchObject({
wslAvailable: true
})
await expect(loadWindowsTerminalCapabilities({ now: 32_000 })).resolves.toMatchObject({
wslAvailable: false
})
expect(wslIsAvailable).toHaveBeenCalledTimes(2)
})
})

View File

@ -0,0 +1,109 @@
import { useEffect, useState } from 'react'
export type WindowsTerminalCapabilities = {
wslAvailable: boolean
pwshAvailable: boolean
}
const UNAVAILABLE_CAPABILITIES: WindowsTerminalCapabilities = {
wslAvailable: false,
pwshAvailable: false
}
const CAPABILITY_CACHE_TTL_MS = 30_000
let cachedCapabilities: WindowsTerminalCapabilities | null = null
let cachedCapabilitiesLoadedAt = 0
let pendingCapabilities: Promise<WindowsTerminalCapabilities> | null = null
let latestCapabilityRequestId = 0
const subscribers = new Set<(capabilities: WindowsTerminalCapabilities) => void>()
function publish(capabilities: WindowsTerminalCapabilities, loadedAt = Date.now()): void {
cachedCapabilities = capabilities
cachedCapabilitiesLoadedAt = loadedAt
for (const subscriber of subscribers) {
subscriber(capabilities)
}
}
export function getCachedWindowsTerminalCapabilities(): WindowsTerminalCapabilities {
return cachedCapabilities ?? UNAVAILABLE_CAPABILITIES
}
export function loadWindowsTerminalCapabilities(
options: {
force?: boolean
now?: number
} = {}
): Promise<WindowsTerminalCapabilities> {
const now = options.now ?? Date.now()
if (
cachedCapabilities &&
!options.force &&
now - cachedCapabilitiesLoadedAt < CAPABILITY_CACHE_TTL_MS
) {
return Promise.resolve(cachedCapabilities)
}
if (pendingCapabilities && !options.force) {
return pendingCapabilities
}
// Why: Settings and the tab bar need one shared answer. Separate probes can
// leave Settings rendering without WSL while the "+" menu already shows it.
const requestId = ++latestCapabilityRequestId
pendingCapabilities = Promise.all([
window.api.wsl.isAvailable().catch(() => false),
window.api.pwsh.isAvailable().catch(() => false)
])
.then(([wslAvailable, pwshAvailable]) => {
const capabilities = { wslAvailable, pwshAvailable }
if (requestId === latestCapabilityRequestId) {
pendingCapabilities = null
publish(capabilities, now)
return capabilities
}
return getCachedWindowsTerminalCapabilities()
})
.catch(() => {
if (requestId === latestCapabilityRequestId) {
pendingCapabilities = null
publish(UNAVAILABLE_CAPABILITIES, now)
return UNAVAILABLE_CAPABILITIES
}
return getCachedWindowsTerminalCapabilities()
})
return pendingCapabilities
}
export function refreshWindowsTerminalCapabilities(): Promise<WindowsTerminalCapabilities> {
return loadWindowsTerminalCapabilities({ force: true })
}
export function useWindowsTerminalCapabilities(enabled: boolean): WindowsTerminalCapabilities {
const [capabilities, setCapabilities] = useState(getCachedWindowsTerminalCapabilities)
useEffect(() => {
if (!enabled) {
setCapabilities(UNAVAILABLE_CAPABILITIES)
return
}
setCapabilities(getCachedWindowsTerminalCapabilities())
subscribers.add(setCapabilities)
void loadWindowsTerminalCapabilities().then(setCapabilities)
return () => {
subscribers.delete(setCapabilities)
}
}, [enabled])
return enabled ? capabilities : UNAVAILABLE_CAPABILITIES
}
export function resetWindowsTerminalCapabilitiesForTests(): void {
cachedCapabilities = null
cachedCapabilitiesLoadedAt = 0
pendingCapabilities = null
latestCapabilityRequestId = 0
subscribers.clear()
}

View File

@ -0,0 +1,140 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { create } from 'zustand'
import type { AppState } from '../types'
import type { Repo, Worktree } from '../../../../shared/types'
import { createDetectedAgentsSlice } from './detected-agents'
const detectAgents = vi.fn()
const refreshAgents = vi.fn()
globalThis.window = {
api: {
preflight: {
detectAgents,
refreshAgents,
detectRemoteAgents: vi.fn().mockResolvedValue([])
}
} as unknown as Window['api']
} as Window & typeof globalThis
function createTestStore(initial?: Partial<AppState>) {
const store = create<AppState>()(
(...a) =>
({
...createDetectedAgentsSlice(...a)
}) as AppState
)
store.setState({
repos: [],
worktreesByRepo: {},
activeRepoId: null,
activeWorktreeId: null,
...initial
} as Partial<AppState>)
return store
}
function makeRepo(overrides: Partial<Repo> & { id: string; path: string }): Repo {
return {
displayName: 'Repo',
badgeColor: '#000000',
addedAt: 0,
...overrides
}
}
function makeWorktree(
overrides: Partial<Worktree> & { id: string; repoId: string; path: string }
): Worktree {
return {
head: 'abc123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: false,
displayName: 'main',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
...overrides
}
}
describe('createDetectedAgentsSlice WSL context', () => {
beforeEach(() => {
detectAgents.mockReset().mockResolvedValue(['claude'])
refreshAgents.mockReset().mockResolvedValue({
agents: ['codex'],
addedPathSegments: [],
shellHydrationOk: true,
pathSource: 'shell_hydrate',
pathFailureReason: 'none'
})
})
it('detects local agents inside the active WSL worktree distro', async () => {
const store = createTestStore({
repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })],
worktreesByRepo: {
'repo-1': [
makeWorktree({
id: 'wt-1',
repoId: 'repo-1',
path: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo'
})
]
},
activeRepoId: 'repo-1',
activeWorktreeId: 'wt-1'
})
await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude'])
expect(detectAgents).toHaveBeenCalledWith({ wslDistro: 'Ubuntu' })
})
it('refreshes local agents inside the active WSL repo distro when no worktree is selected', async () => {
const store = createTestStore({
repos: [makeRepo({ id: 'repo-1', path: '\\\\wsl$\\Debian\\home\\alice\\repo' })],
activeRepoId: 'repo-1',
activeWorktreeId: null
})
await expect(store.getState().refreshDetectedAgents()).resolves.toEqual(['codex'])
expect(refreshAgents).toHaveBeenCalledWith({ wslDistro: 'Debian' })
})
it('does not keep previous context agents when detection fails after a context switch', async () => {
detectAgents
.mockReset()
.mockResolvedValueOnce(['claude'])
.mockRejectedValueOnce(new Error('probe failed'))
const store = createTestStore({
repos: [makeRepo({ id: 'repo-1', path: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo' })],
activeRepoId: 'repo-1',
activeWorktreeId: null
})
await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude'])
expect(store.getState().detectedAgentIds).toEqual(['claude'])
store.setState({
repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })],
activeRepoId: 'repo-1',
activeWorktreeId: null
} as Partial<AppState>)
const detected = store.getState().ensureDetectedAgents()
expect(store.getState().detectedAgentIds).toBeNull()
await expect(detected).resolves.toEqual([])
expect(store.getState().detectedAgentIds).toEqual([])
})
})

View File

@ -1,33 +1,7 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { PathSource, ShellHydrationFailureReason, TuiAgent } from '../../../../shared/types'
type LocalPreflightContext = { wslDistro?: string | null } | undefined
function getWslDistroFromPath(path?: string | null): string | null {
if (!path) {
return null
}
const normalized = path.replace(/\\/g, '/')
const match = normalized.match(/^\/\/(?:wsl\.localhost|wsl\$)\/([^/]+)(?:\/|$)/i)
return match?.[1] ?? null
}
function getLocalPreflightContext(state: AppState): LocalPreflightContext {
const activeWorktree = state.activeWorktreeId
? Object.values(state.worktreesByRepo)
.flat()
.find((worktree) => worktree.id === state.activeWorktreeId)
: null
const activePath =
activeWorktree?.path ?? state.repos.find((repo) => repo.id === state.activeRepoId)?.path
const wslDistro = getWslDistroFromPath(activePath)
return wslDistro ? { wslDistro } : undefined
}
function localPreflightContextKey(context: LocalPreflightContext): string {
return context?.wslDistro ? `wsl:${context.wslDistro}` : 'host'
}
import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context'
export type DetectedAgentsSlice = {
detectedAgentIds: TuiAgent[] | null
@ -83,7 +57,11 @@ export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedA
if (detectPromise?.key === contextKey) {
return detectPromise.promise
}
set({ isDetectingAgents: true })
const contextChanged = detectedContextKey !== contextKey
set({
detectedAgentIds: contextChanged ? null : get().detectedAgentIds,
isDetectingAgents: true
})
const pending = window.api.preflight
.detectAgents(context)
.then((ids) => {
@ -94,9 +72,12 @@ export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedA
})
.catch(() => {
// Why: allow a retry on the next call if detection blew up (IPC timeout
// during cold start). Do not cache the failure.
// during cold start). Do not cache the failure or show stale context.
detectPromise = null
set({ isDetectingAgents: false })
set({
detectedAgentIds: contextChanged ? [] : get().detectedAgentIds,
isDetectingAgents: false
})
return [] as TuiAgent[]
})
detectPromise = { key: contextKey, promise: pending }
@ -109,7 +90,11 @@ export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedA
if (refreshPromise?.key === contextKey) {
return refreshPromise.promise
}
set({ isRefreshingAgents: true })
const contextChanged = detectedContextKey !== contextKey
set({
detectedAgentIds: contextChanged ? null : get().detectedAgentIds,
isRefreshingAgents: true
})
const pending = window.api.preflight
.refreshAgents(context)
.then((result) => {
@ -127,8 +112,12 @@ export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedA
return typed
})
.catch(() => {
set({ isRefreshingAgents: false })
return get().detectedAgentIds ?? []
const fallback = contextChanged ? [] : (get().detectedAgentIds ?? [])
set({
detectedAgentIds: fallback,
isRefreshingAgents: false
})
return fallback
})
.finally(() => {
if (refreshPromise?.promise === pending) {

View File

@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { create } from 'zustand'
import type { PreflightStatus } from '../../../../preload/api-types'
import type { Repo, Worktree } from '../../../../shared/types'
import type { AppState } from '../types'
import { createPreflightSlice } from './preflight'
@ -40,6 +41,39 @@ function makeStatus(glabInstalled: boolean): PreflightStatus {
}
}
function makeRepo(overrides: Partial<Repo> & { id: string; path: string }): Repo {
return {
displayName: 'Repo',
badgeColor: '#000000',
addedAt: 0,
...overrides
}
}
function makeWorktree(
overrides: Partial<Worktree> & { id: string; repoId: string; path: string }
): Worktree {
return {
head: 'abc123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: false,
displayName: 'main',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
...overrides
}
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
@ -105,4 +139,88 @@ describe('createPreflightSlice', () => {
expect(store.getState().preflightStatus?.glab?.installed).toBe(true)
})
it('checks integrations inside the active WSL worktree distro', async () => {
preflightCheck.mockReset()
preflightCheck.mockResolvedValueOnce(makeStatus(true))
const store = createTestStore()
store.setState({
repos: [
makeRepo({
id: 'repo-1',
path: 'C:\\repo'
})
],
worktreesByRepo: {
'repo-1': [
makeWorktree({
id: 'wt-1',
repoId: 'repo-1',
path: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo'
})
]
},
activeRepoId: 'repo-1',
activeWorktreeId: 'wt-1'
} as Partial<AppState>)
await store.getState().refreshPreflightStatus()
expect(preflightCheck).toHaveBeenCalledWith({ wslDistro: 'Ubuntu' })
})
it('keeps preflight request dedupe scoped by WSL distro context', async () => {
preflightCheck.mockReset()
const ubuntu = deferred<PreflightStatus>()
const debian = deferred<PreflightStatus>()
preflightCheck.mockReturnValueOnce(ubuntu.promise).mockReturnValueOnce(debian.promise)
const store = createTestStore()
store.setState({
repos: [makeRepo({ id: 'repo-1', path: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo' })],
worktreesByRepo: {},
activeRepoId: 'repo-1',
activeWorktreeId: null
} as Partial<AppState>)
const first = store.getState().refreshPreflightStatus()
store.setState({
repos: [makeRepo({ id: 'repo-1', path: '\\\\wsl.localhost\\Debian\\home\\alice\\repo' })]
} as Partial<AppState>)
const second = store.getState().refreshPreflightStatus()
expect(preflightCheck).toHaveBeenNthCalledWith(1, { wslDistro: 'Ubuntu' })
expect(preflightCheck).toHaveBeenNthCalledWith(2, { wslDistro: 'Debian' })
ubuntu.resolve(makeStatus(false))
debian.resolve(makeStatus(true))
await Promise.all([first, second])
expect(store.getState().preflightStatus?.glab?.installed).toBe(true)
})
it('clears checked status immediately when refreshing a different local context', async () => {
preflightCheck.mockReset()
const host = deferred<PreflightStatus>()
const wsl = deferred<PreflightStatus>()
preflightCheck.mockReturnValueOnce(host.promise).mockReturnValueOnce(wsl.promise)
const store = createTestStore()
const first = store.getState().refreshPreflightStatus()
host.resolve(makeStatus(true))
await first
expect(store.getState().preflightStatusChecked).toBe(true)
store.setState({
repos: [makeRepo({ id: 'repo-1', path: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo' })],
activeRepoId: 'repo-1',
activeWorktreeId: null
} as Partial<AppState>)
const second = store.getState().refreshPreflightStatus()
expect(store.getState().preflightStatus).toBeNull()
expect(store.getState().preflightStatusChecked).toBe(false)
expect(store.getState().preflightStatusLoading).toBe(true)
wsl.resolve(makeStatus(false))
await second
expect(store.getState().preflightStatus?.glab?.installed).toBe(false)
})
})

View File

@ -1,44 +1,75 @@
import type { StateCreator } from 'zustand'
import type { PreflightStatus } from '../../../../preload/api-types'
import type { AppState } from '../types'
import {
getLocalPreflightContext,
localPreflightContextKey,
type LocalPreflightContext
} from '@/lib/local-preflight-context'
export type PreflightSlice = {
preflightStatus: PreflightStatus | null
preflightStatusChecked: boolean
preflightStatusContextKey: string | null
preflightStatusLoading: boolean
preflightStatusError: string | null
refreshPreflightStatus: (options?: { force?: boolean }) => Promise<void>
}
let nonForcedPreflightRequest: Promise<void> | null = null
let forcedPreflightRequest: Promise<void> | null = null
let nonForcedPreflightRequest: { key: string; promise: Promise<void> } | null = null
let forcedPreflightRequest: { key: string; promise: Promise<void> } | null = null
let latestPreflightRequestId = 0
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Failed to check integrations.'
}
export const createPreflightSlice: StateCreator<AppState, [], [], PreflightSlice> = (set) => ({
function buildPreflightArgs(
force: boolean,
context: LocalPreflightContext
): { force?: boolean; wslDistro?: string | null } | undefined {
if (!force && !context) {
return undefined
}
return {
...(force ? { force: true } : {}),
...context
}
}
export const createPreflightSlice: StateCreator<AppState, [], [], PreflightSlice> = (set, get) => ({
preflightStatus: null,
preflightStatusChecked: false,
preflightStatusContextKey: null,
preflightStatusLoading: false,
preflightStatusError: null,
refreshPreflightStatus: async (options) => {
const force = options?.force === true
if (!force && forcedPreflightRequest) {
return forcedPreflightRequest
const context = getLocalPreflightContext(get())
const contextKey = localPreflightContextKey(context)
if (!force && forcedPreflightRequest?.key === contextKey) {
return forcedPreflightRequest.promise
}
if (!force && nonForcedPreflightRequest) {
return nonForcedPreflightRequest
if (!force && nonForcedPreflightRequest?.key === contextKey) {
return nonForcedPreflightRequest.promise
}
if (force && forcedPreflightRequest?.key === contextKey) {
return forcedPreflightRequest.promise
}
const requestId = ++latestPreflightRequestId
set({ preflightStatusLoading: true, preflightStatusError: null })
const contextChanged = get().preflightStatusContextKey !== contextKey
set({
preflightStatus: contextChanged ? null : get().preflightStatus,
preflightStatusChecked: contextChanged ? false : get().preflightStatusChecked,
preflightStatusLoading: true,
preflightStatusError: null
})
const request = window.api.preflight
.check(force ? { force: true } : undefined)
.check(buildPreflightArgs(force, context))
.then((status) => {
if (requestId !== latestPreflightRequestId) {
return
@ -46,6 +77,7 @@ export const createPreflightSlice: StateCreator<AppState, [], [], PreflightSlice
set({
preflightStatus: status,
preflightStatusChecked: true,
preflightStatusContextKey: contextKey,
preflightStatusLoading: false,
preflightStatusError: null
})
@ -56,23 +88,24 @@ export const createPreflightSlice: StateCreator<AppState, [], [], PreflightSlice
}
set({
preflightStatusChecked: true,
preflightStatusContextKey: contextKey,
preflightStatusLoading: false,
preflightStatusError: getErrorMessage(error)
})
})
.finally(() => {
if (!force && nonForcedPreflightRequest === request) {
if (!force && nonForcedPreflightRequest?.promise === request) {
nonForcedPreflightRequest = null
}
if (force && forcedPreflightRequest === request) {
if (force && forcedPreflightRequest?.promise === request) {
forcedPreflightRequest = null
}
})
if (!force) {
nonForcedPreflightRequest = request
nonForcedPreflightRequest = { key: contextKey, promise: request }
} else {
forcedPreflightRequest = request
forcedPreflightRequest = { key: contextKey, promise: request }
}
return request

View File

@ -718,6 +718,39 @@ describe('setActiveWorktree', () => {
}
})
it('uses WSL as the default shell for WSL worktree terminals on Windows', () => {
const originalNavigator = globalThis.navigator
Object.defineProperty(globalThis, 'navigator', {
value: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' },
configurable: true
})
try {
const store = createTestStore()
const wt = 'repo1::/wsl/path'
seedStore(store, {
settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell: 'powershell.exe' },
worktreesByRepo: {
repo1: [
makeWorktree({
id: wt,
repoId: 'repo1',
path: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
})
]
}
})
const terminal = store.getState().createTab(wt)
expect(terminal.shellOverride).toBe('wsl.exe')
} finally {
Object.defineProperty(globalThis, 'navigator', {
value: originalNavigator,
configurable: true
})
}
})
it('does not stamp local Windows shell icons onto SSH terminal tabs', () => {
const originalNavigator = globalThis.navigator
Object.defineProperty(globalThis, 'navigator', {

View File

@ -11,6 +11,7 @@ import type {
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../../../shared/terminal-tab-id'
import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../../../shared/worktree-id'
import { isWslUncPath } from '../../../../shared/wsl-paths'
import type { AgentStartedTelemetry } from '../../lib/worktree-activation'
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers'
@ -73,7 +74,8 @@ function isWindowsRendererRuntime(): boolean {
function resolveCreatedTabShellOverride(
explicitShellOverride: string | undefined,
defaultWindowsShell: string | undefined,
isRemoteWorktree: boolean
isRemoteWorktree: boolean,
isWslWorktree: boolean
): string | undefined {
if (isRemoteWorktree) {
return undefined
@ -82,11 +84,24 @@ function resolveCreatedTabShellOverride(
return explicitShellOverride
}
if (isWindowsRendererRuntime()) {
if (isWslWorktree) {
return 'wsl.exe'
}
return defaultWindowsShell
}
return undefined
}
function worktreeUsesWslPath(
state: Pick<AppState, 'worktreesByRepo'>,
worktreeId: string
): boolean {
const worktree = Object.values(state.worktreesByRepo)
.flat()
.find((entry) => entry.id === worktreeId)
return worktree ? isWslUncPath(worktree.path) : false
}
function worktreeUsesRemoteConnection(
state: Pick<AppState, 'repos' | 'worktreesByRepo'>,
worktreeId: string
@ -435,7 +450,11 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
s.settings?.terminalWindowsShell,
// Why: SSH PTYs ignore local Windows shell selection; persisting a
// local shell icon would mislabel a remote terminal.
worktreeUsesRemoteConnection(s, worktreeId)
worktreeUsesRemoteConnection(s, worktreeId),
// Why: WSL UNC worktrees are repo-scoped WSL environments. New default
// terminals should enter that distro even when the global Windows shell
// preference is PowerShell or cmd.exe.
worktreeUsesWslPath(s, worktreeId)
)
tab = {
id,

View File

@ -951,7 +951,10 @@ function createCliApi(): NonNullable<Partial<PreloadApi>['cli']> {
return {
getInstallStatus: () => Promise.resolve(status),
install: () => Promise.resolve(status),
remove: () => Promise.resolve(status)
remove: () => Promise.resolve(status),
getWslInstallStatus: () => Promise.resolve(status),
installWsl: () => Promise.resolve(status),
removeWsl: () => Promise.resolve(status)
} as NonNullable<Partial<PreloadApi>['cli']>
}

View File

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { isWslUncPath, parseWslUncPath } from './wsl-paths'
describe('wsl path helpers', () => {
it('parses modern and legacy WSL UNC paths without platform checks', () => {
expect(parseWslUncPath('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo')).toEqual({
distro: 'Ubuntu',
linuxPath: '/home/jin/repo'
})
expect(parseWslUncPath('\\\\wsl$\\Debian\\home\\jin')).toEqual({
distro: 'Debian',
linuxPath: '/home/jin'
})
})
it('rejects ordinary Windows and POSIX paths', () => {
expect(isWslUncPath('C:\\Users\\jin\\repo')).toBe(false)
expect(isWslUncPath('/home/jin/repo')).toBe(false)
})
})

21
src/shared/wsl-paths.ts Normal file
View File

@ -0,0 +1,21 @@
export type WslUncPathInfo = {
distro: string
linuxPath: string
}
export function parseWslUncPath(path: string): WslUncPathInfo | null {
const normalized = path.replace(/\\/g, '/')
const match = normalized.match(/^\/\/(wsl\.localhost|wsl\$)\/([^/]+)(\/.*)?$/i)
if (!match) {
return null
}
return {
distro: match[2],
linuxPath: match[3] || '/'
}
}
export function isWslUncPath(path: string): boolean {
return parseWslUncPath(path) !== null
}