fix(terminal): support Windows shell selection for SSH hosts (#6466)

* fix(terminal): support Windows shell selection for SSH hosts

* fix(runtime): register SSH Windows capability preflight RPC

* fix(terminal): honor remote Git Bash and split preflight IPC seams

* fix(terminal): keep SSH Windows shell state tied to the selected host

* fix(terminal): preserve Windows SSH shell selection

---------

Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
This commit is contained in:
Rod Boev 2026-06-28 18:08:13 -04:00 committed by GitHub
parent 60baa449d4
commit 5e3d8a12bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1437 additions and 226 deletions

View File

@ -0,0 +1,93 @@
import { execFile } from 'child_process'
import path from 'path'
import { promisify } from 'util'
import { buildLocalPreflightEnv } from './preflight-local-env'
import { runPreflightCommandInWsl } from './preflight-wsl-command'
import type { WslPreflightTarget } from './preflight-wsl-agent-detection'
const execFileAsync = promisify(execFile)
export const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000
export type PreflightCommandResult = { stdout: string; stderr: string }
export function shellQuote(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`
}
async function withPreflightTimeout<T>(command: string, commandPromise: Promise<T>): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | null = null
try {
return await Promise.race([
commandPromise,
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => {
const error = Object.assign(new Error(`Timed out running ${command}`), {
code: 'ETIMEDOUT'
})
reject(error)
}, PREFLIGHT_COMMAND_TIMEOUT_MS)
if (typeof timeout.unref === 'function') {
timeout.unref()
}
})
])
} finally {
if (timeout) {
clearTimeout(timeout)
}
}
}
export async function execLocalPreflightCommand(
command: string,
args: string[]
): Promise<PreflightCommandResult> {
const env = buildLocalPreflightEnv()
const commandPromise = execFileAsync(command, args, {
encoding: 'utf-8',
timeout: PREFLIGHT_COMMAND_TIMEOUT_MS,
...(env ? { env } : {})
}) as Promise<PreflightCommandResult>
return withPreflightTimeout(command, commandPromise)
}
export async function execCommandInWsl(
target: WslPreflightTarget,
command: string
): Promise<PreflightCommandResult> {
const commandPromise = runPreflightCommandInWsl(target, command, PREFLIGHT_COMMAND_TIMEOUT_MS)
return withPreflightTimeout('wsl.exe', commandPromise)
}
export async function isCommandAvailable(
command: string,
wslTarget?: WslPreflightTarget
): Promise<boolean> {
try {
await (wslTarget
? execCommandInWsl(wslTarget, `${shellQuote(command)} --version`)
: execLocalPreflightCommand(command, ['--version']))
return true
} catch {
return false
}
}
export async function isCommandOnPath(
command: string,
wslTarget?: WslPreflightTarget
): Promise<boolean> {
const finder = process.platform === 'win32' ? 'where' : 'which'
try {
const { stdout } = wslTarget
? await execCommandInWsl(wslTarget, `command -v ${shellQuote(command)}`)
: await execLocalPreflightCommand(finder, [command])
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.some((line) => path.isAbsolute(line))
} catch {
return false
}
}

View File

@ -0,0 +1,30 @@
import { getActiveMultiplexer } from './ssh'
export type RemoteWindowsTerminalCapabilities = {
wslAvailable: boolean
wslDistros: string[]
pwshAvailable: boolean
gitBashAvailable: boolean
hostPlatform: NodeJS.Platform | null
}
const EMPTY_REMOTE_WINDOWS_TERMINAL_CAPABILITIES: RemoteWindowsTerminalCapabilities = {
wslAvailable: false,
wslDistros: [],
pwshAvailable: false,
gitBashAvailable: false,
hostPlatform: null
}
export async function detectRemoteWindowsTerminalCapabilities(args: {
connectionId: string
}): Promise<RemoteWindowsTerminalCapabilities> {
const mux = getActiveMultiplexer(args.connectionId)
if (!mux || mux.isDisposed()) {
return EMPTY_REMOTE_WINDOWS_TERMINAL_CAPABILITIES
}
const result = (await mux.request('preflight.detectWindowsTerminalCapabilities', {})) as
| RemoteWindowsTerminalCapabilities
| undefined
return result ?? EMPTY_REMOTE_WINDOWS_TERMINAL_CAPABILITIES
}

View File

@ -708,6 +708,35 @@ describe('preflight', () => {
expect(request).not.toHaveBeenCalled()
})
it('sends remote Windows shell capability probes through the SSH preflight path', async () => {
const request = vi.fn().mockResolvedValue({
wslAvailable: true,
wslDistros: ['Ubuntu'],
pwshAvailable: true,
gitBashAvailable: true,
hostPlatform: 'win32'
})
getActiveMultiplexerMock.mockReturnValue({
isDisposed: () => false,
request
})
registerPreflightHandlers()
await expect(
handlers['preflight:detectRemoteWindowsTerminalCapabilities'](undefined, {
connectionId: 'ssh-1'
})
).resolves.toEqual({
wslAvailable: true,
wslDistros: ['Ubuntu'],
pwshAvailable: true,
gitBashAvailable: true,
hostPlatform: 'win32'
})
expect(request).toHaveBeenCalledWith('preflight.detectWindowsTerminalCapabilities', {})
})
it('detects agents from the selected WSL distro for a WSL workspace', async () => {
Object.defineProperty(process, 'platform', {
configurable: true,

View File

@ -1,7 +1,4 @@
import { ipcMain } from 'electron'
import { execFile } from 'child_process'
import { promisify } from 'util'
import path from 'path'
import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
import type { PathSource, ShellHydrationFailureReason } from '../../shared/types'
import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
@ -11,13 +8,20 @@ import { getGiteaAuthStatus } from '../gitea/client'
import { _resetKnownHostsCache } from '../gitlab/gl-utils'
import { getActiveMultiplexer } from './ssh'
import { detectWslCommandsOnPath, type WslPreflightTarget } from './preflight-wsl-agent-detection'
import { runPreflightCommandInWsl } from './preflight-wsl-command'
import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection'
import { buildLocalPreflightEnv } from './preflight-local-env'
import { getPreflightWslTarget, type PreflightRuntimeContext } from './preflight-runtime-target'
import { hydrateShellPathForAgentDetection } from './agent-detection-shell-path'
const execFileAsync = promisify(execFile)
const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000
import {
execCommandInWsl,
execLocalPreflightCommand,
isCommandAvailable,
isCommandOnPath,
shellQuote
} from './preflight-command-exec'
import {
detectRemoteWindowsTerminalCapabilities,
type RemoteWindowsTerminalCapabilities
} from './preflight-remote-windows-terminal-capabilities'
export type PreflightStatus = {
git: { installed: boolean }
@ -44,6 +48,9 @@ export type PreflightStatus = {
}
}
export { detectRemoteWindowsTerminalCapabilities }
export type { RemoteWindowsTerminalCapabilities }
// Why: cache the result so repeated Landing mounts don't re-spawn processes.
// The check only runs once per app session — relaunch to re-check.
let cached: PreflightStatus | null = null
@ -53,92 +60,6 @@ export function _resetPreflightCache(): void {
cached = null
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`
}
type PreflightCommandResult = { stdout: string; stderr: string }
// Why: a broken PATH shim or auth helper should not keep startup/settings
// preflight IPC pending forever; WSL probes already use the same deadline.
async function withPreflightTimeout<T>(command: string, commandPromise: Promise<T>): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | null = null
try {
return await Promise.race([
commandPromise,
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => {
const error = Object.assign(new Error(`Timed out running ${command}`), {
code: 'ETIMEDOUT'
})
reject(error)
}, PREFLIGHT_COMMAND_TIMEOUT_MS)
if (typeof timeout.unref === 'function') {
timeout.unref()
}
})
])
} finally {
if (timeout) {
clearTimeout(timeout)
}
}
}
async function execLocalPreflightCommand(
command: string,
args: string[]
): Promise<PreflightCommandResult> {
const env = buildLocalPreflightEnv()
const commandPromise = execFileAsync(command, args, {
encoding: 'utf-8',
timeout: PREFLIGHT_COMMAND_TIMEOUT_MS,
...(env ? { env } : {})
}) as Promise<PreflightCommandResult>
return withPreflightTimeout(command, commandPromise)
}
async function execCommandInWsl(
target: WslPreflightTarget,
command: string
): Promise<{ stdout: string; stderr: string }> {
const commandPromise = runPreflightCommandInWsl(target, command, PREFLIGHT_COMMAND_TIMEOUT_MS)
return withPreflightTimeout('wsl.exe', commandPromise)
}
async function isCommandAvailable(
command: string,
wslTarget?: WslPreflightTarget
): Promise<boolean> {
try {
await (wslTarget
? execCommandInWsl(wslTarget, `${shellQuote(command)} --version`)
: execLocalPreflightCommand(command, ['--version']))
return true
} catch {
return false
}
}
// Why: `which`/`where` is faster than spawning the agent binary itself and avoids
// triggering any agent-specific startup side-effects. This gives a reliable
// PATH-based check without requiring `--version` support from each agent.
async function isCommandOnPath(command: string, wslTarget?: WslPreflightTarget): Promise<boolean> {
const finder = process.platform === 'win32' ? 'where' : 'which'
try {
const { stdout } = wslTarget
? await execCommandInWsl(wslTarget, `command -v ${shellQuote(command)}`)
: await execLocalPreflightCommand(finder, [command])
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.some((line) => path.isAbsolute(line))
} catch {
return false
}
}
const KNOWN_AGENT_COMMANDS = Object.entries(TUI_AGENT_CONFIG).flatMap(([id, config]) =>
getTuiAgentDetectCommands(config).map((cmd) => ({
id,
@ -378,4 +299,11 @@ export function registerPreflightHandlers(): void {
return detectRemoteAgents(args)
}
)
ipcMain.handle(
'preflight:detectRemoteWindowsTerminalCapabilities',
async (_event, args: { connectionId: string }): Promise<RemoteWindowsTerminalCapabilities> => {
return detectRemoteWindowsTerminalCapabilities(args)
}
)
}

View File

@ -67,6 +67,26 @@ describe('SshPtyProvider', () => {
})
})
it('forwards explicit shellOverride and terminalWindowsWslDistro to the relay mux', async () => {
mux.request.mockResolvedValue({ id: 'pty-2' })
await provider.spawn({
cols: 120,
rows: 40,
shellOverride: 'powershell.exe',
terminalWindowsWslDistro: 'Ubuntu'
})
expect(mux.request).toHaveBeenCalledWith('pty.spawn', {
cols: 120,
rows: 40,
cwd: undefined,
env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' },
shellOverride: 'powershell.exe',
terminalWindowsWslDistro: 'Ubuntu'
})
})
it('preserves an explicit remote Powerlevel10k wizard env value', async () => {
mux.request.mockResolvedValue({ id: 'pty-2' })

View File

@ -136,6 +136,10 @@ export class SshPtyProvider implements IPtyProvider {
// Pi-compatible agent is being launched, while commandDelivery tells it
// whether to submit the command itself for runtime-owned background PTYs.
...(opts.command ? { command: opts.command } : {}),
...(opts.shellOverride !== undefined ? { shellOverride: opts.shellOverride } : {}),
...(opts.terminalWindowsWslDistro !== undefined
? { terminalWindowsWslDistro: opts.terminalWindowsWslDistro }
: {}),
...(opts.commandDelivery ? { commandDelivery: opts.commandDelivery } : {}),
...(opts.startupCommandDelivery
? { startupCommandDelivery: opts.startupCommandDelivery }

View File

@ -7,11 +7,13 @@ import { PREFLIGHT_METHODS } from './preflight'
const {
detectInstalledAgentsWithShellPathHydrationMock,
detectRemoteAgentsMock,
detectRemoteWindowsTerminalCapabilitiesMock,
refreshShellPathAndDetectAgentsMock,
runPreflightCheckMock
} = vi.hoisted(() => ({
detectInstalledAgentsWithShellPathHydrationMock: vi.fn(),
detectRemoteAgentsMock: vi.fn(),
detectRemoteWindowsTerminalCapabilitiesMock: vi.fn(),
refreshShellPathAndDetectAgentsMock: vi.fn(),
runPreflightCheckMock: vi.fn()
}))
@ -19,6 +21,7 @@ const {
vi.mock('../../../ipc/preflight', () => ({
detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock,
detectRemoteAgents: detectRemoteAgentsMock,
detectRemoteWindowsTerminalCapabilities: detectRemoteWindowsTerminalCapabilitiesMock,
refreshShellPathAndDetectAgents: refreshShellPathAndDetectAgentsMock,
runPreflightCheck: runPreflightCheckMock
}))
@ -81,4 +84,36 @@ describe('preflight RPC methods', () => {
expect(detectRemoteAgentsMock).toHaveBeenCalledWith({ connectionId: 'ssh-1' })
expect(response).toMatchObject({ ok: true, result: ['claude'] })
})
it('detects remote Windows terminal capabilities through runtime RPC', async () => {
detectRemoteWindowsTerminalCapabilitiesMock.mockResolvedValueOnce({
wslAvailable: true,
wslDistros: ['Ubuntu'],
pwshAvailable: true,
gitBashAvailable: true,
hostPlatform: 'win32'
})
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: PREFLIGHT_METHODS })
const response = await dispatcher.dispatch(
makeRequest('preflight.detectRemoteWindowsTerminalCapabilities', {
connectionId: 'ssh-1'
})
)
expect(detectRemoteWindowsTerminalCapabilitiesMock).toHaveBeenCalledWith({
connectionId: 'ssh-1'
})
expect(response).toMatchObject({
ok: true,
result: {
wslAvailable: true,
wslDistros: ['Ubuntu'],
pwshAvailable: true,
gitBashAvailable: true,
hostPlatform: 'win32'
}
})
})
})

View File

@ -2,6 +2,7 @@ import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import {
detectRemoteAgents,
detectRemoteWindowsTerminalCapabilities,
detectInstalledAgentsWithShellPathHydration,
refreshShellPathAndDetectAgents,
runPreflightCheck
@ -13,6 +14,9 @@ const PreflightCheck = z.object({
const PreflightDetectRemoteAgents = z.object({
connectionId: z.string().min(1)
})
const PreflightDetectRemoteWindowsTerminalCapabilities = z.object({
connectionId: z.string().min(1)
})
export const PREFLIGHT_METHODS: RpcMethod[] = [
defineMethod({
@ -30,6 +34,11 @@ export const PREFLIGHT_METHODS: RpcMethod[] = [
params: PreflightDetectRemoteAgents,
handler: async (params) => detectRemoteAgents(params)
}),
defineMethod({
name: 'preflight.detectRemoteWindowsTerminalCapabilities',
params: PreflightDetectRemoteWindowsTerminalCapabilities,
handler: async (params) => detectRemoteWindowsTerminalCapabilities(params)
}),
defineMethod({
name: 'preflight.refreshAgents',
params: null,

View File

@ -548,6 +548,13 @@ export type PreflightApi = {
detectAgents: (args?: PreflightRuntimeContext) => Promise<string[]>
refreshAgents: (args?: PreflightRuntimeContext) => Promise<RefreshAgentsResult>
detectRemoteAgents: (args: { connectionId: string }) => Promise<string[]>
detectRemoteWindowsTerminalCapabilities: (args: { connectionId: string }) => Promise<{
wslAvailable: boolean
wslDistros: string[]
pwshAvailable: boolean
gitBashAvailable: boolean
hostPlatform: NodeJS.Platform | null
}>
}
// Why: renderer-facing mirror of the daemon's `SessionInfo` + protocolVersion

View File

@ -1771,7 +1771,16 @@ const api = {
refreshAgents: (args?: PreflightRuntimeContext): Promise<RefreshAgentsResult> =>
ipcRenderer.invoke('preflight:refreshAgents', args),
detectRemoteAgents: (args: { connectionId: string }): Promise<string[]> =>
ipcRenderer.invoke('preflight:detectRemoteAgents', args)
ipcRenderer.invoke('preflight:detectRemoteAgents', args),
detectRemoteWindowsTerminalCapabilities: (args: {
connectionId: string
}): Promise<{
wslAvailable: boolean
wslDistros: string[]
pwshAvailable: boolean
gitBashAvailable: boolean
hostPlatform: NodeJS.Platform | null
}> => ipcRenderer.invoke('preflight:detectRemoteWindowsTerminalCapabilities', args)
},
notifications: {

View File

@ -4,6 +4,14 @@ const { execFileAsyncMock } = vi.hoisted(() => ({
execFileAsyncMock: vi.fn()
}))
const { isPwshAvailableMock, isWslAvailableMock, listWslDistrosMock, isGitBashAvailableMock } =
vi.hoisted(() => ({
isPwshAvailableMock: vi.fn(),
isWslAvailableMock: vi.fn(),
listWslDistrosMock: vi.fn(),
isGitBashAvailableMock: vi.fn()
}))
vi.mock('child_process', () => {
const execFileWithPromisify = Object.assign(vi.fn(), {
[Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock
@ -11,11 +19,19 @@ vi.mock('child_process', () => {
return { execFile: execFileWithPromisify }
})
vi.mock('../main/pwsh', () => ({ isPwshAvailable: isPwshAvailableMock }))
vi.mock('../main/wsl', () => ({
isWslAvailable: isWslAvailableMock,
listWslDistros: listWslDistrosMock
}))
vi.mock('../main/git-bash', () => ({ isGitBashAvailable: isGitBashAvailableMock }))
import {
buildCommandLookupSpec,
buildCommandLookupSpecs,
hasAbsoluteCommandPath,
isCommandOnPathForRelay
isCommandOnPathForRelay,
PreflightHandler
} from './preflight-handler'
function lookupArgs(command: string, mode: '-lc' | '-ilc' = '-lc'): string[] {
@ -43,6 +59,10 @@ function fishLookupArgs(command: string): string[] {
beforeEach(() => {
execFileAsyncMock.mockReset()
isPwshAvailableMock.mockReset()
isWslAvailableMock.mockReset()
listWslDistrosMock.mockReset()
isGitBashAvailableMock.mockReset()
})
describe('buildCommandLookupSpec', () => {
@ -214,3 +234,45 @@ describe('hasAbsoluteCommandPath', () => {
).toBe(true)
})
})
describe('PreflightHandler', () => {
it('reports remote Windows shell capabilities through the SSH preflight path', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
isWslAvailableMock.mockReturnValue(true)
listWslDistrosMock.mockReturnValue(['Ubuntu'])
isPwshAvailableMock.mockReturnValue(true)
isGitBashAvailableMock.mockReturnValue(true)
const requestHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>()
const dispatcher = {
onRequest: vi.fn(
(method: string, handler: (params: Record<string, unknown>) => Promise<unknown>) => {
requestHandlers.set(method, handler)
}
)
}
new PreflightHandler(dispatcher as never)
try {
const handler = requestHandlers.get('preflight.detectWindowsTerminalCapabilities')
expect(handler).toBeDefined()
await expect(handler!({})).resolves.toEqual({
wslAvailable: true,
wslDistros: ['Ubuntu'],
pwshAvailable: true,
gitBashAvailable: true,
hostPlatform: 'win32'
})
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
})

View File

@ -4,6 +4,9 @@ import { promisify } from 'util'
import path, { win32 } from 'path'
import type { RelayDispatcher } from './dispatcher'
import { buildRelayCommandEnv } from './relay-command-env'
import { isPwshAvailable } from '../main/pwsh'
import { isWslAvailable, listWslDistros } from '../main/wsl'
import { isGitBashAvailable } from '../main/git-bash'
const execFileAsync = promisify(execFile)
@ -33,6 +36,9 @@ export class PreflightHandler {
private registerHandlers(): void {
this.dispatcher.onRequest('preflight.detectAgents', (p) => this.detectAgents(p))
this.dispatcher.onRequest('preflight.detectWindowsTerminalCapabilities', () =>
this.detectWindowsTerminalCapabilities()
)
}
// Why: the client sends the command list rather than importing TUI_AGENT_CONFIG
@ -54,6 +60,28 @@ export class PreflightHandler {
return { agents: [...new Set(results.filter((r) => r.installed).map((r) => r.id))] }
}
private async detectWindowsTerminalCapabilities(): Promise<{
wslAvailable: boolean
wslDistros: string[]
pwshAvailable: boolean
gitBashAvailable: boolean
hostPlatform: NodeJS.Platform | null
}> {
const [wslAvailable, pwshAvailable, gitBashAvailable] = await Promise.all([
Promise.resolve(isWslAvailable()).catch(() => false),
Promise.resolve(isPwshAvailable()).catch(() => false),
Promise.resolve(isGitBashAvailable()).catch(() => false)
])
const wslDistros = wslAvailable ? await Promise.resolve(listWslDistros()).catch(() => []) : []
return {
wslAvailable,
wslDistros,
pwshAvailable,
gitBashAvailable,
hostPlatform: process.platform
}
}
// Why: SSH exec channels give the relay a minimal environment without shell
// startup files sourced. Ask the user's configured shell so agent dirs added
// by zsh/bash/fish startup hooks match the remote terminal experience.

View File

@ -4,6 +4,8 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types'
import * as gitBash from '../main/git-bash'
import * as ptyShellUtils from './pty-shell-utils'
import {
resolveSetupAgentSequenceLaunchCommand,
SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV
@ -169,6 +171,155 @@ describe('PtyHandler', () => {
expect(handler.activePtyCount).toBe(1)
})
it('uses an explicit shell override and falls back to the default shell otherwise', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
const resolveDefaultShellSpy = vi
.spyOn(ptyShellUtils, 'resolveDefaultShell')
.mockReturnValue('/default-shell')
try {
await dispatcher.callRequest('pty.spawn', {
cols: 80,
rows: 24,
shellOverride: 'powershell.exe'
})
expect(mockPtySpawn).toHaveBeenCalledWith(
'powershell.exe',
expect.any(Array),
expect.any(Object)
)
mockPtySpawn.mockClear()
await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 })
expect(mockPtySpawn).toHaveBeenCalledWith(
'/default-shell',
expect.any(Array),
expect.any(Object)
)
} finally {
resolveDefaultShellSpy.mockRestore()
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('ignores Windows shell overrides on non-Windows relay hosts', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux'
})
const resolveDefaultShellSpy = vi
.spyOn(ptyShellUtils, 'resolveDefaultShell')
.mockReturnValue('/default-shell')
try {
await dispatcher.callRequest('pty.spawn', {
cols: 80,
rows: 24,
shellOverride: 'powershell.exe'
})
expect(mockPtySpawn).toHaveBeenCalledWith(
'/default-shell',
expect.any(Array),
expect.any(Object)
)
} finally {
resolveDefaultShellSpy.mockRestore()
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('rejects unsupported shell overrides on Windows relay hosts', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
try {
await expect(
dispatcher.callRequest('pty.spawn', {
cols: 80,
rows: 24,
shellOverride: 'notepad.exe'
})
).rejects.toThrow('Unsupported Windows shell override')
expect(mockPtySpawn).not.toHaveBeenCalled()
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('resolves the Git Bash sentinel to the remote bash.exe path on Windows', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
const resolveGitBashSpy = vi
.spyOn(gitBash, 'resolveWindowsGitBashShellPath')
.mockReturnValue('C:\\Program Files\\Git\\bin\\bash.exe')
try {
await dispatcher.callRequest('pty.spawn', {
cols: 80,
rows: 24,
shellOverride: 'git-bash'
})
expect(resolveGitBashSpy).toHaveBeenCalledWith('git-bash')
expect(mockPtySpawn).toHaveBeenCalledWith(
'C:\\Program Files\\Git\\bin\\bash.exe',
expect.any(Array),
expect.any(Object)
)
} finally {
resolveGitBashSpy.mockRestore()
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('passes the selected WSL distro to relay launches on Windows', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
try {
await dispatcher.callRequest('pty.spawn', {
cols: 80,
rows: 24,
shellOverride: 'wsl.exe',
terminalWindowsWslDistro: 'Ubuntu-24.04'
})
expect(mockPtySpawn).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu-24.04'],
expect.any(Object)
)
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('keeps SSH spawn commands as hints unless provider delivery is requested', async () => {
await dispatcher.callRequest('pty.spawn', { command: 'echo renderer-owned' })

View File

@ -1,6 +1,8 @@
/* oxlint-disable max-lines */
import type { IPty } from 'node-pty'
import type * as NodePty from 'node-pty'
import { resolveWindowsGitBashShellPath } from '../main/git-bash'
import { WINDOWS_GIT_BASH_SHELL } from '../shared/windows-terminal-shell'
import type { RelayDispatcher, RequestContext } from './dispatcher'
import {
resolveDefaultShell,
@ -128,6 +130,32 @@ const ALLOWED_SIGNALS = new Set([
'SIGUSR2'
])
const ALLOWED_WINDOWS_SHELL_OVERRIDES = new Set([
'powershell.exe',
'powershell',
'pwsh.exe',
'pwsh',
'cmd.exe',
'cmd',
'wsl.exe',
'wsl',
WINDOWS_GIT_BASH_SHELL
])
function resolvePtyShellOverride(shellOverride: string): string {
if (!shellOverride) {
return ''
}
if (process.platform !== 'win32') {
return ''
}
const normalized = shellOverride.toLowerCase()
if (!ALLOWED_WINDOWS_SHELL_OVERRIDES.has(normalized)) {
throw new Error(`Unsupported Windows shell override: ${shellOverride}`)
}
return resolveWindowsGitBashShellPath(shellOverride) ?? shellOverride
}
type SerializedPtyEntry = {
id: string
pid: number
@ -497,7 +525,10 @@ export class PtyHandler {
const rows = (params.rows as number) || 24
const cwd = (params.cwd as string) || resolveDefaultCwd()
const env = params.env as Record<string, string> | undefined
const shell = resolveDefaultShell()
const shellOverride =
typeof params.shellOverride === 'string' ? params.shellOverride.trim() : ''
const resolvedShellOverride = resolvePtyShellOverride(shellOverride)
const shell = resolvedShellOverride || resolveDefaultShell()
const id = `pty-${this.nextId++}`
// Why: server-side augmenter values (ORCA_AGENT_HOOK_* and plugin overlay
@ -509,6 +540,8 @@ export class PtyHandler {
// because no renderer TerminalPane exists to type the command.
const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined
const command = typeof params.command === 'string' ? params.command : undefined
const terminalWindowsWslDistro =
typeof params.terminalWindowsWslDistro === 'string' ? params.terminalWindowsWslDistro : null
const commandDelivery = params.commandDelivery === 'provider' ? 'provider' : 'renderer'
const shouldProviderDeliverCommand = commandDelivery === 'provider' && command !== undefined
const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell, command })
@ -523,6 +556,7 @@ export class PtyHandler {
// Why: renderer- and provider-delivered startup commands both use this
// marker; the side responsible for delivery also strips it from output.
const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, {
terminalWindowsWslDistro,
emitReadyMarker: shouldEmitShellReadyMarker
})

View File

@ -25,7 +25,10 @@ function shellBasename(shellPath: string): string {
return shellPath.replace(/\\/g, '/').split('/').pop()?.toLowerCase() ?? ''
}
function windowsShellArgs(shellName: string): string[] | null {
function windowsShellArgs(
shellName: string,
options: { terminalWindowsWslDistro?: string | null } = {}
): string[] | null {
if (shellName === 'powershell.exe' || shellName === 'powershell') {
return ['-NoLogo']
}
@ -35,6 +38,10 @@ function windowsShellArgs(shellName: string): string[] | null {
if (shellName === 'cmd.exe' || shellName === 'cmd') {
return []
}
if (shellName === 'wsl.exe' || shellName === 'wsl') {
const distro = options.terminalWindowsWslDistro?.trim()
return distro ? ['-d', distro] : []
}
return null
}
@ -247,14 +254,20 @@ export function getRelayShellLaunchConfig(
shellPath: string,
env: Record<string, string>,
platform: NodeJS.Platform = process.platform,
options: { emitReadyMarker?: boolean } = {}
options: { emitReadyMarker?: boolean; terminalWindowsWslDistro?: string | null } = {}
): RelayShellLaunchConfig {
const shellName = shellBasename(shellPath)
const emitReadyMarker = options.emitReadyMarker === true
if (platform === 'win32') {
// Why: pwsh also exists on POSIX remotes; Windows-specific shell args must
// only apply when the relay itself is running on native Windows.
return { args: windowsShellArgs(shellName) ?? [], env: {} }
return {
args:
windowsShellArgs(shellName, {
terminalWindowsWslDistro: options.terminalWindowsWslDistro
}) ?? [],
env: {}
}
}
if (shellName !== 'zsh' && shellName !== 'bash') {

View File

@ -307,12 +307,18 @@ function TabBarInner({
const activeRuntimeEnvironmentId = useAppStore(
(s) => getRuntimeEnvironmentIdForWorktree(s, worktreeId)?.trim() || null
)
const worktreeHasRemoteConnection = useAppStore((s) => {
const worktreeConnectionId = useAppStore((s) => {
const worktree = Object.values(s.worktreesByRepo ?? {})
.flat()
.find((entry) => entry.id === worktreeId)
const repo = worktree ? s.repos?.find((entry) => entry.id === worktree.repoId) : null
return Boolean(repo?.connectionId)
return repo?.connectionId?.trim() || null
})
const worktreeRemotePlatform = useAppStore((s) => {
if (!worktreeConnectionId) {
return null
}
return s.sshConnectionStates.get(worktreeConnectionId)?.remotePlatform ?? null
})
const defaultAgent = useAppStore((s) => s.settings?.defaultTuiAgent)
const agentCmdOverrides = useAppStore(
@ -361,29 +367,36 @@ function TabBarInner({
)
const isWebClient = (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ === true
const windowsTerminalCapabilityOwnerKey = getWindowsTerminalCapabilityOwnerKey(
activeRuntimeEnvironmentId
activeRuntimeEnvironmentId,
worktreeConnectionId
)
const runtimeTarget = useMemo(
() => getActiveRuntimeTarget({ activeRuntimeEnvironmentId }),
[activeRuntimeEnvironmentId]
)
const shouldProbeWindowsShellCapabilities =
(isWindows || Boolean(activeRuntimeEnvironmentId?.trim()) || isWebClient) &&
!worktreeHasRemoteConnection
isWindows ||
Boolean(activeRuntimeEnvironmentId?.trim()) ||
isWebClient ||
Boolean(worktreeConnectionId)
const windowsTerminalCapabilities = useWindowsTerminalCapabilities(
shouldProbeWindowsShellCapabilities,
false,
windowsTerminalCapabilityOwnerKey,
runtimeTarget
runtimeTarget,
worktreeConnectionId
)
const shellMenuHostPlatform = worktreeConnectionId
? (worktreeRemotePlatform ?? windowsTerminalCapabilities.hostPlatform)
: windowsTerminalCapabilities.hostPlatform
const showWindowsShellMenu = shouldShowWindowsShellMenu({
activeRuntimeEnvironmentId,
hostPlatform: windowsTerminalCapabilities.hostPlatform,
hostPlatform: shellMenuHostPlatform,
isWindowsClient: isWindows,
worktreeHasRemoteConnection
worktreeHasRemoteConnection: Boolean(worktreeConnectionId)
})
const localProjectRuntime = useMemo(() => {
if (!showWindowsShellMenu || activeRuntimeEnvironmentId?.trim()) {
if (!showWindowsShellMenu || activeRuntimeEnvironmentId?.trim() || worktreeConnectionId) {
return undefined
}
return getLocalProjectExecutionRuntimeContext(
@ -414,6 +427,7 @@ function TabBarInner({
repos,
settings,
showWindowsShellMenu,
worktreeConnectionId,
windowsTerminalCapabilities.isLoading,
windowsTerminalCapabilities.wslAvailable,
windowsTerminalCapabilities.wslDistros,

View File

@ -20,6 +20,7 @@ const appStoreSnapshot: {
sourceRepoIds?: string[]
}[]
repos: { id: string; connectionId?: string | null }[]
sshConnectionStates: Map<string, { remotePlatform?: NodeJS.Platform }>
worktreesByRepo: Record<
string,
{ id: string; repoId: string; path?: string; projectId?: string }[]
@ -38,6 +39,7 @@ const appStoreSnapshot: {
activeWorktreeId: null,
projects: [],
repos: [],
sshConnectionStates: new Map(),
worktreesByRepo: {},
unifiedTabsByWorktree: {},
activeGroupIdByWorktree: {},
@ -59,6 +61,7 @@ const useAppStoreMock = vi.fn(
gitStatusByWorktree: Record<string, never[]>
projects: typeof appStoreSnapshot.projects
repos: { id: string; connectionId?: string | null }[]
sshConnectionStates: Map<string, { remotePlatform?: NodeJS.Platform }>
worktreesByRepo: typeof appStoreSnapshot.worktreesByRepo
unifiedTabsByWorktree: Record<string, unknown[]>
activeGroupIdByWorktree: Record<string, string>
@ -84,6 +87,7 @@ const useAppStoreMock = vi.fn(
gitStatusByWorktree: {},
projects: appStoreSnapshot.projects,
repos: appStoreSnapshot.repos,
sshConnectionStates: appStoreSnapshot.sshConnectionStates,
worktreesByRepo: appStoreSnapshot.worktreesByRepo,
unifiedTabsByWorktree: appStoreSnapshot.unifiedTabsByWorktree,
activeGroupIdByWorktree: appStoreSnapshot.activeGroupIdByWorktree,
@ -159,6 +163,7 @@ useAppStoreExport.getState = vi.fn(() => ({
gitStatusByWorktree: {},
projects: appStoreSnapshot.projects,
repos: appStoreSnapshot.repos,
sshConnectionStates: appStoreSnapshot.sshConnectionStates,
worktreesByRepo: appStoreSnapshot.worktreesByRepo,
unifiedTabsByWorktree: appStoreSnapshot.unifiedTabsByWorktree,
activeGroupIdByWorktree: appStoreSnapshot.activeGroupIdByWorktree,
@ -339,6 +344,7 @@ describe('TabBar PowerShell launch wiring', () => {
appStoreSnapshot.activeWorktreeId = null
appStoreSnapshot.projects = []
appStoreSnapshot.repos = []
appStoreSnapshot.sshConnectionStates = new Map()
appStoreSnapshot.worktreesByRepo = {}
appStoreSnapshot.unifiedTabsByWorktree = {}
appStoreSnapshot.activeGroupIdByWorktree = {}
@ -701,24 +707,101 @@ describe('TabBar PowerShell launch wiring', () => {
expect(onNewTerminalWithShell).toHaveBeenCalledWith('git-bash')
})
it('hides local Windows shell rows for SSH worktrees', async () => {
it('shows the Windows shell rows for an SSH Windows host', async () => {
appStoreSnapshot.repos = [{ id: 'repo-1', connectionId: 'ssh-1' }]
appStoreSnapshot.sshConnectionStates = new Map([['ssh-1', { remotePlatform: 'win32' }]])
appStoreSnapshot.worktreesByRepo = {
'repo-1': [{ id: 'wt-ssh', repoId: 'repo-1' }]
}
const detectRemoteWindowsTerminalCapabilities = vi.fn().mockResolvedValue({
wslAvailable: true,
wslDistros: ['Ubuntu'],
pwshAvailable: true,
gitBashAvailable: true,
hostPlatform: 'win32'
})
vi.stubGlobal('window', {
api: {
wsl: {
isAvailable: vi.fn().mockResolvedValue(true),
listDistros: vi.fn().mockResolvedValue(['Ubuntu'])
},
pwsh: { isAvailable: vi.fn().mockResolvedValue(true) },
gitBash: { isAvailable: vi.fn().mockResolvedValue(true) },
runtime: { getStatus: vi.fn().mockResolvedValue({ hostPlatform: 'win32' }) }
preflight: {
detectRemoteWindowsTerminalCapabilities
}
}
})
const capabilities = await import('@/lib/windows-terminal-capabilities')
await capabilities.loadWindowsTerminalCapabilities()
await capabilities.loadWindowsTerminalCapabilities({
ownerKey: 'ssh:ssh-1',
sshConnectionId: 'ssh-1'
})
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 onNewTerminalWithShell = vi.fn()
const element = TabBar!({
tabs: [],
activeTabId: null,
worktreeId: 'wt-ssh',
expandedPaneByTabId: {},
onActivate: () => {},
onClose: () => {},
onCloseOthers: () => {},
onCloseToRight: () => {},
onNewTerminalTab: () => {},
onNewTerminalWithShell,
onNewBrowserTab: () => {},
onSetCustomTitle: () => {},
onSetTabColor: () => {},
onTogglePaneExpand: () => {}
})
const powerShellItem = findDropdownMenuItemByText(
expandNode(element),
'New Terminal: PowerShell'
)
expect(powerShellItem).not.toBeNull()
expect(
findDropdownMenuItemByText(expandNode(element), 'New Terminal: CMD Prompt')
).not.toBeNull()
expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: Git Bash')).not.toBeNull()
expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: WSL')).not.toBeNull()
const onSelect = powerShellItem?.props.onSelect as (() => void) | undefined
onSelect?.()
expect(onNewTerminalWithShell).toHaveBeenCalledWith('pwsh.exe')
})
it('keeps SSH Linux hosts on the generic new-terminal entry', async () => {
appStoreSnapshot.repos = [{ id: 'repo-1', connectionId: 'ssh-1' }]
appStoreSnapshot.sshConnectionStates = new Map([['ssh-1', { remotePlatform: 'linux' }]])
appStoreSnapshot.worktreesByRepo = {
'repo-1': [{ id: 'wt-ssh', repoId: 'repo-1' }]
}
const detectRemoteWindowsTerminalCapabilities = vi.fn().mockResolvedValue({
wslAvailable: false,
wslDistros: [],
pwshAvailable: false,
gitBashAvailable: false,
hostPlatform: 'linux'
})
vi.stubGlobal('window', {
api: {
preflight: {
detectRemoteWindowsTerminalCapabilities
}
}
})
const capabilities = await import('@/lib/windows-terminal-capabilities')
await capabilities.loadWindowsTerminalCapabilities({
ownerKey: 'ssh:ssh-1',
sshConnectionId: 'ssh-1'
})
const tabBarModule = await import('./TabBar')
const candidate = tabBarModule.default ?? tabBarModule
@ -747,8 +830,10 @@ describe('TabBar PowerShell launch wiring', () => {
onTogglePaneExpand: () => {}
})
expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: Git Bash')).toBeNull()
expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: PowerShell')).toBeNull()
expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: CMD Prompt')).toBeNull()
expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: Git Bash')).toBeNull()
expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: WSL')).toBeNull()
expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal')).not.toBeNull()
})

View File

@ -46,7 +46,7 @@ describe('shouldShowWindowsShellMenu', () => {
).toBe(false)
})
it('hides local Windows shells for SSH worktrees', () => {
it('shows Windows shells for SSH worktrees when the remote host is Windows', () => {
expect(
shouldShowWindowsShellMenu({
activeRuntimeEnvironmentId: null,
@ -54,6 +54,17 @@ describe('shouldShowWindowsShellMenu', () => {
isWindowsClient: true,
worktreeHasRemoteConnection: true
})
).toBe(true)
})
it('hides Windows shells for SSH worktrees when the remote host is Linux', () => {
expect(
shouldShowWindowsShellMenu({
activeRuntimeEnvironmentId: null,
hostPlatform: 'linux',
isWindowsClient: true,
worktreeHasRemoteConnection: true
})
).toBe(false)
})
})

View File

@ -8,9 +8,8 @@ export function shouldShowWindowsShellMenu(args: {
// to be Windows, local Windows shell choices would advertise the wrong target.
const runtimeHostIsNotKnownWindows =
Boolean(args.activeRuntimeEnvironmentId?.trim()) && args.hostPlatform !== 'win32'
return (
(args.isWindowsClient || args.hostPlatform === 'win32') &&
!args.worktreeHasRemoteConnection &&
!runtimeHostIsNotKnownWindows
)
if (args.worktreeHasRemoteConnection) {
return args.hostPlatform === 'win32'
}
return (args.isWindowsClient || args.hostPlatform === 'win32') && !runtimeHostIsNotKnownWindows
}

View File

@ -898,7 +898,10 @@ function WorkspaceCleanupFilterToolbar({
<Input
value={filters.query}
onChange={(event) => updateFilter('query', event.target.value)}
placeholder="Search workspaces"
placeholder={translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4361f1534c',
'Search workspaces'
)}
className="h-8 pl-8 text-xs"
/>
</div>
@ -941,7 +944,10 @@ function WorkspaceCleanupFilterToolbar({
)}
</DropdownMenuLabel>
<WorkspaceCleanupMenuSub<WorkspaceCleanupTimeFilter>
label="Age"
label={translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.6cd8439929',
'Age'
)}
value={filters.time}
options={[
['all', 'Any age'],
@ -952,7 +958,10 @@ function WorkspaceCleanupFilterToolbar({
onChange={(value) => updateFilter('time', value)}
/>
<WorkspaceCleanupMenuSub<WorkspaceCleanupReviewFilter>
label="Review"
label={translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.38422e7b8d',
'Review'
)}
value={filters.review}
options={[
['all', 'Any review'],
@ -964,7 +973,10 @@ function WorkspaceCleanupFilterToolbar({
onChange={(value) => updateFilter('review', value)}
/>
<WorkspaceCleanupMenuSub<WorkspaceCleanupGitFilter>
label="Git"
label={translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.6359dc079a',
'Git'
)}
value={filters.git}
options={[
['all', 'Any git'],
@ -976,7 +988,10 @@ function WorkspaceCleanupFilterToolbar({
onChange={(value) => updateFilter('git', value)}
/>
<WorkspaceCleanupMenuSub<WorkspaceCleanupContextFilter>
label="Context"
label={translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7644af86b2',
'Context'
)}
value={filters.context}
options={[
['all', 'Any context'],
@ -993,7 +1008,10 @@ function WorkspaceCleanupFilterToolbar({
)}
</DropdownMenuLabel>
<WorkspaceCleanupMenuSub<WorkspaceCleanupSortKey>
label="Sort by"
label={translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e343b9e9ce',
'Sort by'
)}
value={sortKey}
options={[
['activity', 'Activity'],
@ -1005,7 +1023,10 @@ function WorkspaceCleanupFilterToolbar({
onChange={onSortKeyChange}
/>
<WorkspaceCleanupMenuSub<WorkspaceCleanupSortDirection>
label="Direction"
label={translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.27acbc2efe',
'Direction'
)}
value={sortDirection}
options={[
['asc', 'Ascending'],

View File

@ -2,6 +2,7 @@ import type { AppState } from '@/store/types'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { Repo, Worktree } from '../../../../shared/types'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import { translate } from '@/i18n/i18n'
import type { WorkspaceCleanupFilters } from './workspace-cleanup-presentation'
export const NOW = 1_700_000_000_000
@ -84,7 +85,10 @@ export function makeReview(overrides: Partial<HostedReviewInfo> = {}): HostedRev
return {
provider: 'github',
number: 42,
title: 'Review alpha cleanup',
title: translate(
'auto.components.workspace.cleanup.presentationFixtures.5ed71d83ef',
'Review alpha cleanup'
),
state: 'open',
url: 'https://example.test/review/42',
status: 'neutral',

View File

@ -1,6 +1,7 @@
import { getWorktreeMapFromState } from '@/store/selectors'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review'
import type { AppState } from '@/store/types'
import { translate } from '@/i18n/i18n'
import type { HostedReviewInfo, HostedReviewProvider } from '../../../../shared/hosted-review'
import type { Repo, Worktree } from '../../../../shared/types'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
@ -110,10 +111,22 @@ function getLinkedReviewFallback(worktree: Worktree | null): {
return null
}
if (worktree.linkedGitLabMR != null) {
return { label: `MR #${worktree.linkedGitLabMR}`, provider: 'gitlab' }
return {
label: `${translate(
'auto.components.workspace.cleanup.presentation.0bb8d1aa02',
'MR #'
)}${worktree.linkedGitLabMR}`,
provider: 'gitlab'
}
}
if (worktree.linkedPR != null) {
return { label: `PR #${worktree.linkedPR}`, provider: 'github' }
return {
label: `${translate(
'auto.components.workspace.cleanup.presentation.b1f1a02943',
'PR #'
)}${worktree.linkedPR}`,
provider: 'github'
}
}
return null
}

View File

@ -2237,7 +2237,32 @@
"2ddbd6fe8a": "checked",
"ee81adfcef": "View",
"4d0b72481c": "Ignore",
"9cc26c019d": "Remove"
"9cc26c019d": "Remove",
"0e2d235c63": "Inactive workspace scan ready",
"4a35c08764": "Review",
"47123d0108": "Scanning inactive workspaces. You can close this and come back.",
"9a3be9f2df": "Scanning inactive workspaces. New rows appear here as they finish. You can close this and come back.",
"3d957ff117": "No workspaces match these filters.",
"e94b1f8bb4": "Clear filters",
"efb3843e75": "Filter and sort workspaces",
"93b7381d50": "Filters",
"a615e24679": "Sort",
"4cc5b73efe": "Finding inactive workspaces...",
"5bf2e88480": "{{value0}}/{{value1}} {{value2}} scanned",
"4361f1534c": "Search workspaces",
"6cd8439929": "Age",
"38422e7b8d": "Review",
"6359dc079a": "Git",
"7644af86b2": "Context",
"e343b9e9ce": "Sort by",
"27acbc2efe": "Direction"
},
"presentation": {
"0bb8d1aa02": "MR #",
"b1f1a02943": "PR #"
},
"presentationFixtures": {
"5ed71d83ef": "Review alpha cleanup"
}
}
},

View File

@ -2237,7 +2237,32 @@
"2ddbd6fe8a": "comprobado",
"ee81adfcef": "Vista",
"4d0b72481c": "Ignorar",
"9cc26c019d": "Eliminar"
"9cc26c019d": "Eliminar",
"0e2d235c63": "Inactive workspace scan ready",
"4a35c08764": "Review",
"47123d0108": "Scanning inactive workspaces. You can close this and come back.",
"9a3be9f2df": "Scanning inactive workspaces. New rows appear here as they finish. You can close this and come back.",
"3d957ff117": "No workspaces match these filters.",
"e94b1f8bb4": "Clear filters",
"efb3843e75": "Filter and sort workspaces",
"93b7381d50": "Filters",
"a615e24679": "Sort",
"4cc5b73efe": "Finding inactive workspaces...",
"5bf2e88480": "{{value0}}/{{value1}} {{value2}} scanned",
"4361f1534c": "Search workspaces",
"6cd8439929": "Age",
"38422e7b8d": "Review",
"6359dc079a": "Git",
"7644af86b2": "Context",
"e343b9e9ce": "Sort by",
"27acbc2efe": "Direction"
},
"presentation": {
"0bb8d1aa02": "MR #",
"b1f1a02943": "PR #"
},
"presentationFixtures": {
"5ed71d83ef": "Review alpha cleanup"
}
}
},

View File

@ -2237,7 +2237,32 @@
"2ddbd6fe8a": "チェック済み",
"ee81adfcef": "表示",
"4d0b72481c": "無視",
"9cc26c019d": "削除"
"9cc26c019d": "削除",
"0e2d235c63": "Inactive workspace scan ready",
"4a35c08764": "Review",
"47123d0108": "Scanning inactive workspaces. You can close this and come back.",
"9a3be9f2df": "Scanning inactive workspaces. New rows appear here as they finish. You can close this and come back.",
"3d957ff117": "No workspaces match these filters.",
"e94b1f8bb4": "Clear filters",
"efb3843e75": "Filter and sort workspaces",
"93b7381d50": "Filters",
"a615e24679": "Sort",
"4cc5b73efe": "Finding inactive workspaces...",
"5bf2e88480": "{{value0}}/{{value1}} {{value2}} scanned",
"4361f1534c": "Search workspaces",
"6cd8439929": "Age",
"38422e7b8d": "Review",
"6359dc079a": "Git",
"7644af86b2": "Context",
"e343b9e9ce": "Sort by",
"27acbc2efe": "Direction"
},
"presentation": {
"0bb8d1aa02": "MR #",
"b1f1a02943": "PR #"
},
"presentationFixtures": {
"5ed71d83ef": "Review alpha cleanup"
}
}
},

View File

@ -2237,7 +2237,32 @@
"2ddbd6fe8a": "확인됨",
"ee81adfcef": "보기",
"4d0b72481c": "무시",
"9cc26c019d": "제거"
"9cc26c019d": "제거",
"0e2d235c63": "Inactive workspace scan ready",
"4a35c08764": "Review",
"47123d0108": "Scanning inactive workspaces. You can close this and come back.",
"9a3be9f2df": "Scanning inactive workspaces. New rows appear here as they finish. You can close this and come back.",
"3d957ff117": "No workspaces match these filters.",
"e94b1f8bb4": "Clear filters",
"efb3843e75": "Filter and sort workspaces",
"93b7381d50": "Filters",
"a615e24679": "Sort",
"4cc5b73efe": "Finding inactive workspaces...",
"5bf2e88480": "{{value0}}/{{value1}} {{value2}} scanned",
"4361f1534c": "Search workspaces",
"6cd8439929": "Age",
"38422e7b8d": "Review",
"6359dc079a": "Git",
"7644af86b2": "Context",
"e343b9e9ce": "Sort by",
"27acbc2efe": "Direction"
},
"presentation": {
"0bb8d1aa02": "MR #",
"b1f1a02943": "PR #"
},
"presentationFixtures": {
"5ed71d83ef": "Review alpha cleanup"
}
}
},

View File

@ -2237,7 +2237,32 @@
"2ddbd6fe8a": "检查过",
"ee81adfcef": "查看",
"4d0b72481c": "忽略",
"9cc26c019d": "移除"
"9cc26c019d": "移除",
"0e2d235c63": "Inactive workspace scan ready",
"4a35c08764": "Review",
"47123d0108": "Scanning inactive workspaces. You can close this and come back.",
"9a3be9f2df": "Scanning inactive workspaces. New rows appear here as they finish. You can close this and come back.",
"3d957ff117": "No workspaces match these filters.",
"e94b1f8bb4": "Clear filters",
"efb3843e75": "Filter and sort workspaces",
"93b7381d50": "Filters",
"a615e24679": "Sort",
"4cc5b73efe": "Finding inactive workspaces...",
"5bf2e88480": "{{value0}}/{{value1}} {{value2}} scanned",
"4361f1534c": "Search workspaces",
"6cd8439929": "Age",
"38422e7b8d": "Review",
"6359dc079a": "Git",
"7644af86b2": "Context",
"e343b9e9ce": "Sort by",
"27acbc2efe": "Direction"
},
"presentation": {
"0bb8d1aa02": "MR #",
"b1f1a02943": "PR #"
},
"presentationFixtures": {
"5ed71d83ef": "Review alpha cleanup"
}
}
},

View File

@ -1,11 +1,17 @@
// @vitest-environment happy-dom
import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
getCachedWindowsTerminalCapabilities,
getWindowsTerminalCapabilityOwnerKey,
hasCachedWindowsTerminalCapabilities,
loadWindowsTerminalCapabilities,
refreshWindowsTerminalCapabilities,
resetWindowsTerminalCapabilitiesForTests,
selectWindowsTerminalCapabilitiesForOwner
selectWindowsTerminalCapabilitiesForOwner,
useWindowsTerminalCapabilities
} from './windows-terminal-capabilities'
function stubTerminalCapabilityApi(args: {
@ -42,7 +48,12 @@ function stubTerminalCapabilityApi(args: {
}
describe('windows terminal capabilities', () => {
const hookRoots: Root[] = []
afterEach(() => {
for (const root of hookRoots.splice(0)) {
act(() => root.unmount())
}
resetWindowsTerminalCapabilitiesForTests()
vi.unstubAllGlobals()
})
@ -258,6 +269,246 @@ describe('windows terminal capabilities', () => {
)
})
it('loads SSH Windows host capabilities through the SSH preflight bridge', async () => {
const detectRemoteWindowsTerminalCapabilities = vi.fn().mockResolvedValue({
wslAvailable: true,
wslDistros: ['Ubuntu'],
pwshAvailable: true,
gitBashAvailable: true,
hostPlatform: 'win32'
})
vi.stubGlobal('window', {
api: {
preflight: {
detectRemoteWindowsTerminalCapabilities
}
}
})
await expect(
loadWindowsTerminalCapabilities({
ownerKey: 'ssh:ssh-1',
sshConnectionId: 'ssh-1'
})
).resolves.toEqual({
wslAvailable: true,
wslDistros: ['Ubuntu'],
pwshAvailable: true,
gitBashAvailable: true,
hostPlatform: 'win32',
isLoading: false
})
expect(detectRemoteWindowsTerminalCapabilities).toHaveBeenCalledWith({
connectionId: 'ssh-1'
})
expect(getCachedWindowsTerminalCapabilities('ssh:ssh-1')).toEqual({
wslAvailable: true,
wslDistros: ['Ubuntu'],
pwshAvailable: true,
gitBashAvailable: true,
hostPlatform: 'win32',
isLoading: false
})
})
it('derives the SSH owner cache key when callers omit ownerKey', async () => {
const detectRemoteWindowsTerminalCapabilities = vi
.fn()
.mockResolvedValueOnce({
wslAvailable: true,
wslDistros: ['Ubuntu'],
pwshAvailable: true,
gitBashAvailable: true,
hostPlatform: 'win32'
})
.mockResolvedValueOnce({
wslAvailable: true,
wslDistros: ['Ubuntu', 'Debian'],
pwshAvailable: true,
gitBashAvailable: false,
hostPlatform: 'win32'
})
vi.stubGlobal('window', {
api: {
preflight: {
detectRemoteWindowsTerminalCapabilities
}
}
})
const sshOwnerKey = getWindowsTerminalCapabilityOwnerKey(null, 'ssh-1')
await expect(loadWindowsTerminalCapabilities({ sshConnectionId: 'ssh-1' })).resolves.toEqual({
wslAvailable: true,
wslDistros: ['Ubuntu'],
pwshAvailable: true,
gitBashAvailable: true,
hostPlatform: 'win32',
isLoading: false
})
expect(getCachedWindowsTerminalCapabilities(sshOwnerKey)).toEqual({
wslAvailable: true,
wslDistros: ['Ubuntu'],
pwshAvailable: true,
gitBashAvailable: true,
hostPlatform: 'win32',
isLoading: false
})
expect(getCachedWindowsTerminalCapabilities()).toEqual({
wslAvailable: false,
wslDistros: [],
pwshAvailable: false,
gitBashAvailable: false,
hostPlatform: null,
isLoading: false
})
await expect(
refreshWindowsTerminalCapabilities(undefined, { kind: 'local' }, 'ssh-1')
).resolves.toEqual({
wslAvailable: true,
wslDistros: ['Ubuntu', 'Debian'],
pwshAvailable: true,
gitBashAvailable: false,
hostPlatform: 'win32',
isLoading: false
})
expect(getCachedWindowsTerminalCapabilities(sshOwnerKey)).toEqual({
wslAvailable: true,
wslDistros: ['Ubuntu', 'Debian'],
pwshAvailable: true,
gitBashAvailable: false,
hostPlatform: 'win32',
isLoading: false
})
})
it('loads runtime-owned SSH capabilities through runtime RPC with a scoped cache key', async () => {
const detectRemoteWindowsTerminalCapabilities = vi.fn()
const runtimeEnvironmentCall = vi.fn(async (args: { selector: string; method: string }) => {
const resultByMethod: Record<string, unknown> = {
'status.get': {
hostPlatform: 'linux',
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 2
},
'preflight.detectRemoteWindowsTerminalCapabilities': {
wslAvailable: true,
wslDistros: ['Ubuntu'],
pwshAvailable: true,
gitBashAvailable: false,
hostPlatform: 'win32'
}
}
return {
id: args.method,
ok: true,
result: resultByMethod[args.method]
}
})
vi.stubGlobal('window', {
api: {
preflight: {
detectRemoteWindowsTerminalCapabilities
},
runtimeEnvironments: {
call: runtimeEnvironmentCall
}
}
})
const ownerKey = getWindowsTerminalCapabilityOwnerKey('env-1', 'ssh-1')
expect(ownerKey).toBe('runtime:env-1:ssh:ssh-1')
await expect(
loadWindowsTerminalCapabilities({
target: { kind: 'environment', environmentId: 'env-1' },
sshConnectionId: 'ssh-1'
})
).resolves.toEqual({
wslAvailable: true,
wslDistros: ['Ubuntu'],
pwshAvailable: true,
gitBashAvailable: false,
hostPlatform: 'win32',
isLoading: false
})
expect(detectRemoteWindowsTerminalCapabilities).not.toHaveBeenCalled()
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
expect.objectContaining({
selector: 'env-1',
method: 'preflight.detectRemoteWindowsTerminalCapabilities',
params: { connectionId: 'ssh-1' }
})
)
expect(getCachedWindowsTerminalCapabilities(ownerKey)).toEqual({
wslAvailable: true,
wslDistros: ['Ubuntu'],
pwshAvailable: true,
gitBashAvailable: false,
hostPlatform: 'win32',
isLoading: false
})
expect(getCachedWindowsTerminalCapabilities('ssh:ssh-1')).toEqual({
wslAvailable: false,
wslDistros: [],
pwshAvailable: false,
gitBashAvailable: false,
hostPlatform: null,
isLoading: false
})
})
it('does not re-probe on parent rerenders with the same capability target', async () => {
const detectRemoteWindowsTerminalCapabilities = vi.fn().mockResolvedValue({
wslAvailable: true,
wslDistros: ['Ubuntu'],
pwshAvailable: true,
gitBashAvailable: true,
hostPlatform: 'win32'
})
vi.stubGlobal('window', {
api: {
preflight: {
detectRemoteWindowsTerminalCapabilities
}
}
})
function HookProbe(): null {
useWindowsTerminalCapabilities(true, false, undefined, { kind: 'local' }, 'ssh-1')
return null
}
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
hookRoots.push(root)
await act(async () => {
root.render(createElement(HookProbe))
})
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
expect(detectRemoteWindowsTerminalCapabilities).toHaveBeenCalledTimes(1)
await act(async () => {
root.render(createElement(HookProbe))
})
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
expect(detectRemoteWindowsTerminalCapabilities).toHaveBeenCalledTimes(1)
})
it('prunes expired runtime owner capability caches', async () => {
stubTerminalCapabilityApi({
wslAvailable: false,

View File

@ -1,6 +1,8 @@
import { useEffect, useState } from 'react'
import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
import type { RuntimeStatus } from '../../../shared/runtime-types'
import { useEffect, useMemo, useState } from 'react'
import {
readWindowsTerminalCapabilities,
type WindowsTerminalCapabilityLoadTarget
} from './windows-terminal-capability-read'
export type WindowsTerminalCapabilities = {
wslAvailable: boolean
@ -39,14 +41,33 @@ type WindowsTerminalCapabilityHookState = {
capabilities: WindowsTerminalCapabilities
}
type WindowsTerminalCapabilityLoadTarget = RuntimeClientTarget
function resolveWindowsTerminalCapabilityCacheKey(args: {
ownerKey?: string
target?: WindowsTerminalCapabilityLoadTarget
sshConnectionId?: string | null
}): string {
const explicitOwnerKey = args.ownerKey?.trim()
if (explicitOwnerKey) {
return explicitOwnerKey
}
const environmentId = args.target?.kind === 'environment' ? args.target.environmentId : null
return getWindowsTerminalCapabilityOwnerKey(environmentId, args.sshConnectionId)
}
export function getWindowsTerminalCapabilityOwnerKey(
activeRuntimeEnvironmentId?: string | null
activeRuntimeEnvironmentId?: string | null,
sshConnectionId?: string | null
): string {
// Why: remote desktop and paired web clients can switch hosts; Git Bash/WSL availability is
// host-owned, so a previous runtime's answer must not bleed into the next.
const connectionId = sshConnectionId?.trim()
const environmentId = activeRuntimeEnvironmentId?.trim()
if (connectionId && environmentId) {
return `runtime:${environmentId}:ssh:${connectionId}`
}
if (connectionId) {
return `ssh:${connectionId}`
}
return environmentId ? `runtime:${environmentId}` : 'local'
}
@ -105,11 +126,17 @@ export function loadWindowsTerminalCapabilities(
now?: number
ownerKey?: string
target?: WindowsTerminalCapabilityLoadTarget
sshConnectionId?: string | null
} = {}
): Promise<WindowsTerminalCapabilities> {
const now = options.now ?? Date.now()
const ownerKey = options.ownerKey ?? 'local'
const sshConnectionId = options.sshConnectionId?.trim() || null
const target = options.target ?? { kind: 'local' }
const ownerKey = resolveWindowsTerminalCapabilityCacheKey({
ownerKey: options.ownerKey,
target,
sshConnectionId
})
pruneExpiredCapabilityOwners(now)
const cached = cachedCapabilitiesByOwnerKey.get(ownerKey)
if (cached && !options.force && now - cached.loadedAt < CAPABILITY_CACHE_TTL_MS) {
@ -124,16 +151,8 @@ export function loadWindowsTerminalCapabilities(
// Separate probes can leave one surface showing stale Windows shell choices.
const requestId = ++nextCapabilityRequestId
latestCapabilityRequestIdByOwnerKey.set(ownerKey, requestId)
const nextPendingCapabilities = Promise.all(readWindowsTerminalCapabilityPromises(target))
.then(([wslAvailable, wslDistros, pwshAvailable, gitBashAvailable, hostPlatform]) => {
const capabilities = {
wslAvailable,
wslDistros,
pwshAvailable,
gitBashAvailable,
hostPlatform,
isLoading: false
}
const nextPendingCapabilities = readWindowsTerminalCapabilities(target, sshConnectionId)
.then((capabilities) => {
if (requestId === latestCapabilityRequestIdByOwnerKey.get(ownerKey)) {
pendingCapabilitiesByOwnerKey.delete(ownerKey)
publish(capabilities, ownerKey, now)
@ -155,10 +174,11 @@ export function loadWindowsTerminalCapabilities(
}
export function refreshWindowsTerminalCapabilities(
ownerKey = 'local',
target: WindowsTerminalCapabilityLoadTarget = { kind: 'local' }
ownerKey: string | undefined = undefined,
target: WindowsTerminalCapabilityLoadTarget = { kind: 'local' },
sshConnectionId?: string | null
): Promise<WindowsTerminalCapabilities> {
return loadWindowsTerminalCapabilities({ force: true, ownerKey, target })
return loadWindowsTerminalCapabilities({ force: true, ownerKey, target, sshConnectionId })
}
export function selectWindowsTerminalCapabilitiesForOwner(
@ -171,107 +191,76 @@ export function selectWindowsTerminalCapabilitiesForOwner(
}
return state.ownerKey === ownerKey
? state.capabilities
: (cachedCapabilitiesByOwnerKey.get(ownerKey)?.capabilities ?? UNAVAILABLE_CAPABILITIES)
: getCachedWindowsTerminalCapabilities(ownerKey)
}
export function useWindowsTerminalCapabilities(
enabled: boolean,
forceRefreshOnMount = false,
ownerKey = 'local',
target: WindowsTerminalCapabilityLoadTarget = { kind: 'local' }
ownerKey: string | undefined = undefined,
target: WindowsTerminalCapabilityLoadTarget = { kind: 'local' },
sshConnectionId?: string | null
): WindowsTerminalCapabilities {
const targetKind = target.kind
const targetEnvironmentId = target.kind === 'environment' ? target.environmentId : null
const [state, setState] = useState(() => ({
const sshConnectionIdKey = sshConnectionId?.trim() || null
const resolvedTarget: WindowsTerminalCapabilityLoadTarget = useMemo(
() =>
targetKind === 'environment' && targetEnvironmentId
? { kind: 'environment', environmentId: targetEnvironmentId }
: { kind: 'local' },
[targetKind, targetEnvironmentId]
)
const resolvedOwnerKey = resolveWindowsTerminalCapabilityCacheKey({
ownerKey,
capabilities: getCachedWindowsTerminalCapabilities(ownerKey)
target: resolvedTarget,
sshConnectionId: sshConnectionIdKey
})
const [state, setState] = useState(() => ({
ownerKey: resolvedOwnerKey,
capabilities: getCachedWindowsTerminalCapabilities(resolvedOwnerKey)
}))
useEffect(() => {
if (!enabled) {
setState({ ownerKey, capabilities: UNAVAILABLE_CAPABILITIES })
setState({ ownerKey: resolvedOwnerKey, capabilities: UNAVAILABLE_CAPABILITIES })
return
}
const loadTarget: WindowsTerminalCapabilityLoadTarget =
targetKind === 'environment' && targetEnvironmentId
? { kind: 'environment', environmentId: targetEnvironmentId }
: { kind: 'local' }
let cancelled = false
const cached = getCachedWindowsTerminalCapabilities(ownerKey)
const hasOwnerCache = cachedCapabilitiesByOwnerKey.has(ownerKey)
const cached = getCachedWindowsTerminalCapabilities(resolvedOwnerKey)
const hasOwnerCache = cachedCapabilitiesByOwnerKey.has(resolvedOwnerKey)
setState({
ownerKey,
ownerKey: resolvedOwnerKey,
capabilities: hasOwnerCache ? cached : { ...cached, isLoading: true }
})
const setCapabilities = (capabilities: WindowsTerminalCapabilities): void => {
setState({ ownerKey, capabilities })
setState({ ownerKey: resolvedOwnerKey, capabilities })
}
const subscribers = subscribersByOwnerKey.get(ownerKey) ?? new Set()
const subscribers = subscribersByOwnerKey.get(resolvedOwnerKey) ?? new Set()
subscribers.add(setCapabilities)
subscribersByOwnerKey.set(ownerKey, subscribers)
subscribersByOwnerKey.set(resolvedOwnerKey, subscribers)
void loadWindowsTerminalCapabilities({
force: forceRefreshOnMount,
ownerKey,
target: loadTarget
ownerKey: resolvedOwnerKey,
target: resolvedTarget,
sshConnectionId: sshConnectionIdKey
}).then((nextCapabilities) => {
if (!cancelled) {
setState({ ownerKey, capabilities: nextCapabilities })
setState({ ownerKey: resolvedOwnerKey, capabilities: nextCapabilities })
}
})
return () => {
cancelled = true
const currentSubscribers = subscribersByOwnerKey.get(ownerKey)
const currentSubscribers = subscribersByOwnerKey.get(resolvedOwnerKey)
currentSubscribers?.delete(setCapabilities)
if (currentSubscribers?.size === 0) {
subscribersByOwnerKey.delete(ownerKey)
subscribersByOwnerKey.delete(resolvedOwnerKey)
}
}
}, [enabled, forceRefreshOnMount, ownerKey, targetKind, targetEnvironmentId])
}, [enabled, forceRefreshOnMount, resolvedOwnerKey, resolvedTarget, sshConnectionIdKey])
return selectWindowsTerminalCapabilitiesForOwner(state, enabled, ownerKey)
}
function readWindowsTerminalCapabilityPromises(
target: WindowsTerminalCapabilityLoadTarget
): [
Promise<boolean>,
Promise<string[]>,
Promise<boolean>,
Promise<boolean>,
Promise<NodeJS.Platform | null>
] {
if (target.kind === 'local') {
return [
window.api.wsl.isAvailable().catch(() => false),
window.api.wsl.listDistros().catch(() => []),
window.api.pwsh.isAvailable().catch(() => false),
window.api.gitBash.isAvailable().catch(() => false),
window.api.runtime
.getStatus()
.then((status) => status.hostPlatform ?? null)
.catch(() => null)
]
}
return [
callRuntimeRpc<boolean>(target, 'host.wsl.isAvailable', undefined, { timeoutMs: 15_000 }).catch(
() => false
),
callRuntimeRpc<string[]>(target, 'host.wsl.listDistros', undefined, {
timeoutMs: 15_000
}).catch(() => []),
callRuntimeRpc<boolean>(target, 'host.pwsh.isAvailable', undefined, {
timeoutMs: 15_000
}).catch(() => false),
callRuntimeRpc<boolean>(target, 'host.gitBash.isAvailable', undefined, {
timeoutMs: 15_000
}).catch(() => false),
callRuntimeRpc<RuntimeStatus>(target, 'status.get', undefined, { timeoutMs: 15_000 })
.then((status) => status.hostPlatform ?? null)
.catch(() => null)
]
return selectWindowsTerminalCapabilitiesForOwner(state, enabled, resolvedOwnerKey)
}
export function resetWindowsTerminalCapabilitiesForTests(): void {

View File

@ -0,0 +1,87 @@
import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
import type { RuntimeStatus } from '../../../shared/runtime-types'
import type { WindowsTerminalCapabilities } from './windows-terminal-capabilities'
export type WindowsTerminalCapabilityLoadTarget = RuntimeClientTarget
export async function readWindowsTerminalCapabilities(
target: WindowsTerminalCapabilityLoadTarget,
sshConnectionId?: string | null
): Promise<WindowsTerminalCapabilities> {
if (sshConnectionId) {
const remoteCapabilityPromise =
target.kind === 'environment'
? callRuntimeRpc<Omit<WindowsTerminalCapabilities, 'isLoading'>>(
target,
'preflight.detectRemoteWindowsTerminalCapabilities',
{ connectionId: sshConnectionId },
{ timeoutMs: 15_000 }
)
: window.api.preflight.detectRemoteWindowsTerminalCapabilities({
connectionId: sshConnectionId
})
return remoteCapabilityPromise
.then((capabilities) => ({
...capabilities,
wslDistros: capabilities.wslDistros ?? [],
isLoading: false
}))
.catch(() => ({
wslAvailable: false,
wslDistros: [],
pwshAvailable: false,
gitBashAvailable: false,
hostPlatform: null,
isLoading: false
}))
}
if (target.kind === 'local') {
const [wslAvailable, wslDistros, pwshAvailable, gitBashAvailable, hostPlatform] =
await Promise.all([
window.api.wsl.isAvailable().catch(() => false),
window.api.wsl.listDistros().catch(() => []),
window.api.pwsh.isAvailable().catch(() => false),
window.api.gitBash.isAvailable().catch(() => false),
window.api.runtime
.getStatus()
.then((status) => status.hostPlatform ?? null)
.catch(() => null)
])
return {
wslAvailable,
wslDistros,
pwshAvailable,
gitBashAvailable,
hostPlatform,
isLoading: false
}
}
const [wslAvailable, wslDistros, pwshAvailable, gitBashAvailable, hostPlatform] =
await Promise.all([
callRuntimeRpc<boolean>(target, 'host.wsl.isAvailable', undefined, {
timeoutMs: 15_000
}).catch(() => false),
callRuntimeRpc<string[]>(target, 'host.wsl.listDistros', undefined, {
timeoutMs: 15_000
}).catch(() => []),
callRuntimeRpc<boolean>(target, 'host.pwsh.isAvailable', undefined, {
timeoutMs: 15_000
}).catch(() => false),
callRuntimeRpc<boolean>(target, 'host.gitBash.isAvailable', undefined, {
timeoutMs: 15_000
}).catch(() => false),
callRuntimeRpc<RuntimeStatus>(target, 'status.get', undefined, { timeoutMs: 15_000 })
.then((status) => status.hostPlatform ?? null)
.catch(() => null)
])
return {
wslAvailable,
wslDistros,
pwshAvailable,
gitBashAvailable,
hostPlatform,
isLoading: false
}
}

View File

@ -1342,6 +1342,104 @@ describe('setActiveWorktree', () => {
}
})
it('preserves explicit Windows shell selections for Windows SSH terminal tabs', () => {
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 = 'remote-repo::/path/wt1'
seedStore(store, {
repos: [
{
id: 'remote-repo',
path: '/remote/repo',
displayName: 'Remote Repo',
badgeColor: '#000',
addedAt: 0,
connectionId: 'ssh-1'
}
],
sshConnectionStates: new Map([
[
'ssh-1',
{
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0,
remotePlatform: 'win32'
}
]
]),
settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell: 'wsl.exe' },
worktreesByRepo: {
'remote-repo': [makeWorktree({ id: wt, repoId: 'remote-repo', path: '/path/wt1' })]
}
})
const terminal = store.getState().createTab(wt, undefined, 'cmd.exe')
expect(terminal.shellOverride).toBe('cmd.exe')
} finally {
Object.defineProperty(globalThis, 'navigator', {
value: originalNavigator,
configurable: true
})
}
})
it('drops explicit Windows shell selections for non-Windows SSH terminal tabs', () => {
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 = 'remote-repo::/path/wt1'
seedStore(store, {
repos: [
{
id: 'remote-repo',
path: '/remote/repo',
displayName: 'Remote Repo',
badgeColor: '#000',
addedAt: 0,
connectionId: 'ssh-1'
}
],
sshConnectionStates: new Map([
[
'ssh-1',
{
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0,
remotePlatform: 'linux'
}
]
]),
settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell: 'wsl.exe' },
worktreesByRepo: {
'remote-repo': [makeWorktree({ id: wt, repoId: 'remote-repo', path: '/path/wt1' })]
}
})
const terminal = store.getState().createTab(wt, undefined, 'cmd.exe')
expect(terminal.shellOverride).toBeUndefined()
} finally {
Object.defineProperty(globalThis, 'navigator', {
value: originalNavigator,
configurable: true
})
}
})
it('does not offer Git Bash as a local shell override for SSH terminal tabs', () => {
const originalNavigator = globalThis.navigator
Object.defineProperty(globalThis, 'navigator', {

View File

@ -27,6 +27,7 @@ import { isWslUncPath } from '../../../../shared/wsl-paths'
import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime'
import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery'
import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../../shared/local-windows-terminal-runtime'
import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell'
import type { AgentStartedTelemetry } from '../../lib/worktree-activation'
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers'
@ -169,14 +170,28 @@ function isWindowsRendererRuntime(): boolean {
return typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows')
}
function isAllowedRemoteWindowsTerminalShell(shell: string | undefined): boolean {
return (
shell === 'powershell.exe' ||
shell === 'pwsh.exe' ||
shell === 'cmd.exe' ||
shell === 'wsl.exe' ||
shell === WINDOWS_GIT_BASH_SHELL
)
}
function resolveCreatedTabShellOverride(
explicitShellOverride: string | undefined,
defaultWindowsShell: string | undefined,
isRemoteWorktree: boolean,
remotePlatform: NodeJS.Platform | null,
isWslWorktree: boolean,
projectRuntime: ProjectExecutionRuntimeResolution | undefined
): string | undefined {
if (isRemoteWorktree) {
if (remotePlatform === 'win32' && isAllowedRemoteWindowsTerminalShell(explicitShellOverride)) {
return explicitShellOverride
}
return undefined
}
if (isWindowsRendererRuntime()) {
@ -231,6 +246,27 @@ export function worktreeUsesRemoteConnection(
return Boolean(repo?.connectionId)
}
function getRemoteConnectionIdForWorktree(
state: Pick<AppState, 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'>,
worktreeId: string
): string | null {
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
if (parsedWorkspaceKey?.type === 'folder') {
return getFolderWorkspaceConnectionId(state, parsedWorkspaceKey.folderWorkspaceId) ?? null
}
const directRepoId = getRepoIdFromWorktreeId(worktreeId)
const directRepo = state.repos.find((repo) => repo.id === directRepoId)
if (directRepo) {
return directRepo.connectionId?.trim() || null
}
const worktree = Object.values(state.worktreesByRepo)
.flat()
.find((entry) => entry.id === worktreeId)
const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null
return repo?.connectionId?.trim() || null
}
function resolveTerminalStopRuntimeEnvironmentId(
state: Pick<AppState, 'repos' | 'settings' | 'worktreesByRepo'>,
worktreeId: string
@ -686,7 +722,8 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
const nextOrdinal = getNextTerminalOrdinal(existing)
const defaultTitle = `Terminal ${nextOrdinal}`
const quickCommandLabel = options?.quickCommandLabel?.trim()
const isRemoteWorktree = worktreeUsesRemoteConnection(s, worktreeId)
const remoteConnectionId = getRemoteConnectionIdForWorktree(s, worktreeId)
const isRemoteWorktree = Boolean(remoteConnectionId)
const isWslWorktree = worktreeUsesWslPath(s, worktreeId)
const createdShellOverride = resolveCreatedTabShellOverride(
shellOverride,
@ -694,6 +731,10 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
// Why: SSH PTYs ignore local Windows shell selection; persisting a
// local shell icon would mislabel a remote terminal.
isRemoteWorktree,
remoteConnectionId
? ((s.sshConnectionStates.get(remoteConnectionId)
?.remotePlatform as NodeJS.Platform | null) ?? null)
: null,
// 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.

View File

@ -2179,6 +2179,20 @@ function createPreflightApi(): NonNullable<Partial<PreloadApi>['preflight']> {
pathSource: 'sync_seed_only',
pathFailureReason: 'spawn_error'
}
type WindowsTerminalCapabilityBridgeResult = {
wslAvailable: boolean
wslDistros: string[]
pwshAvailable: boolean
gitBashAvailable: boolean
hostPlatform: NodeJS.Platform | null
}
const fallbackWindowsTerminalCapabilities = {
wslAvailable: false,
wslDistros: [],
pwshAvailable: false,
gitBashAvailable: false,
hostPlatform: null
}
return {
check: async (args) => {
if (!requireActiveEnvironmentOrNull()) {
@ -2201,7 +2215,14 @@ function createPreflightApi(): NonNullable<Partial<PreloadApi>['preflight']> {
detectRemoteAgents: async (args) =>
requireActiveEnvironmentOrNull()
? callRuntimeResult<string[]>('preflight.detectRemoteAgents', args).catch(() => [])
: []
: [],
detectRemoteWindowsTerminalCapabilities: async (args) =>
requireActiveEnvironmentOrNull()
? callRuntimeResult<WindowsTerminalCapabilityBridgeResult>(
'preflight.detectRemoteWindowsTerminalCapabilities',
args
).catch(() => fallbackWindowsTerminalCapabilities)
: Promise.resolve(fallbackWindowsTerminalCapabilities)
}
}