Support remote host Windows terminal UI (#4589)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-06-03 16:21:43 -07:00
committed by GitHub
co-authored by Orca
parent 0bbd103f5f
commit d9f2d4ac6c
9 changed files with 286 additions and 43 deletions
@@ -55,6 +55,7 @@ import {
getWindowsTerminalCapabilityOwnerKey,
useWindowsTerminalCapabilities
} from '@/lib/windows-terminal-capabilities'
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { keybindingMatchesAction } from '../../../../shared/keybindings'
import {
@@ -583,19 +584,29 @@ function Settings(): React.JSX.Element {
const windowsTerminalCapabilityOwnerKey = getWindowsTerminalCapabilityOwnerKey(
settings?.activeRuntimeEnvironmentId
)
// Why: General owns the Orca CLI controls, including WSL skill-location setup.
const windowsTerminalCapabilities = useWindowsTerminalCapabilities(
(isWindows || isWebClient) &&
const runtimeTarget = useMemo(
() => getActiveRuntimeTarget(settings),
[settings?.activeRuntimeEnvironmentId]
)
const hasActiveRuntimeEnvironment = Boolean(settings?.activeRuntimeEnvironmentId?.trim())
const shouldLoadWindowsTerminalCapabilities =
hasActiveRuntimeEnvironment ||
((isWindows || isWebClient) &&
(neededSectionIds.has('terminal') ||
neededSectionIds.has('general') ||
neededSectionIds.has('accounts') ||
neededSectionIds.has('agents')),
neededSectionIds.has('agents')))
// Why: General owns the Orca CLI controls, including WSL skill-location setup.
const windowsTerminalCapabilities = useWindowsTerminalCapabilities(
shouldLoadWindowsTerminalCapabilities,
true,
windowsTerminalCapabilityOwnerKey
windowsTerminalCapabilityOwnerKey,
runtimeTarget
)
// Why: WSL can be unsupported on macOS/Linux, or supported-but-unavailable on Windows.
// Only the latter should render disabled WSL controls.
const wslSupportedPlatform = isWindows || windowsTerminalCapabilities.hostPlatform === 'win32'
const isWindowsTerminalHost = isWindows || windowsTerminalCapabilities.hostPlatform === 'win32'
if ([...neededSectionIds].some((id) => !mountedSectionIds.has(id))) {
// Why: lazy Settings sections are remembered for the session; record newly
@@ -1074,6 +1085,7 @@ function Settings(): React.JSX.Element {
wslCapabilitiesLoading={windowsTerminalCapabilities.isLoading}
pwshAvailable={windowsTerminalCapabilities.pwshAvailable}
gitBashAvailable={windowsTerminalCapabilities.gitBashAvailable}
isWindowsTerminalHost={isWindowsTerminalHost}
/>
) : null}
</SettingsSection>
@@ -261,6 +261,30 @@ describe('TerminalPane PowerShell version setting', () => {
expect(collectText(element)).toContain('WSL')
})
it('shows Windows shell controls for a remote Windows host on a non-Windows client', () => {
const element = TerminalPane({
settings: {
terminalScrollbackBytes: 10_000_000,
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'auto',
terminalWordSeparator: ''
} as never,
updateSettings: () => {},
scrollbackMode: 'preset',
setScrollbackMode: () => {},
wslAvailable: true,
wslDistros: ['Ubuntu'],
pwshAvailable: false,
gitBashAvailable: false,
isWindowsTerminalHost: true
})
const text = collectText(element)
expect(text).toContain('Default shell for new terminal panes on Windows')
expect(text).toContain('Command Prompt')
expect(text).toContain('WSL')
})
it('hides WSL as a Windows default shell option when unavailable', () => {
const element = TerminalPane({
settings: {
@@ -53,6 +53,8 @@ type TerminalPaneProps = {
pwshAvailable?: boolean
/** Whether Git for Windows bash.exe is installed on this machine. */
gitBashAvailable?: boolean
/** Whether the active terminal host is Windows, even if the client is not. */
isWindowsTerminalHost?: boolean
}
export function TerminalPane({
@@ -64,10 +66,12 @@ export function TerminalPane({
wslDistros = EMPTY_WSL_DISTROS,
wslCapabilitiesLoading = false,
pwshAvailable,
gitBashAvailable = false
gitBashAvailable = false,
isWindowsTerminalHost
}: TerminalPaneProps): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const isWindows = isWindowsUserAgent()
const showWindowsHostSettings = isWindowsTerminalHost ?? isWindows
const isMac = isMacUserAgent()
const detectedLayout = useDetectedOptionAsAlt()
const detectedLayoutLabel =
@@ -90,11 +94,13 @@ export function TerminalPane({
? [selectedWslDistroName, ...wslDistros]
: wslDistros
const powerShellImplementation = settings.terminalWindowsPowerShellImplementation ?? 'auto'
const showWindowsPowerShellImplementation = isWindows && windowsShell === 'powershell.exe'
const showWindowsPowerShellImplementation =
showWindowsHostSettings && windowsShell === 'powershell.exe'
const showGitBashOption = gitBashAvailable || windowsShell === WINDOWS_GIT_BASH_SHELL
const visibleSections = [
isWindows && matchesSettingsSearch(searchQuery, TERMINAL_WINDOWS_SHELL_SEARCH_ENTRY) ? (
showWindowsHostSettings &&
matchesSettingsSearch(searchQuery, TERMINAL_WINDOWS_SHELL_SEARCH_ENTRY) ? (
<section key="windows-shell" className="space-y-3">
<SettingsSubsectionHeader
title="Windows Shell"
@@ -55,6 +55,7 @@ import {
getWindowsTerminalCapabilityOwnerKey,
useWindowsTerminalCapabilities
} from '@/lib/windows-terminal-capabilities'
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
type StatusBarProps = {
floatingTerminalOpen: boolean
@@ -602,10 +603,16 @@ function ClaudeSwitcherMenu({
const inactiveClaudeAccounts = useAppStore((s) => s.rateLimits.inactiveClaudeAccounts)
const claudeTarget = useAppStore((s) => s.rateLimits.claudeTarget)
const settings = useAppStore((s) => s.settings)
const hasActiveRuntimeEnvironment = Boolean(settings?.activeRuntimeEnvironmentId?.trim())
const runtimeTarget = useMemo(
() => getActiveRuntimeTarget(settings),
[settings?.activeRuntimeEnvironmentId]
)
const windowsTerminalCapabilities = useWindowsTerminalCapabilities(
navigator.userAgent.includes('Windows'),
navigator.userAgent.includes('Windows') || hasActiveRuntimeEnvironment,
false,
getWindowsTerminalCapabilityOwnerKey(settings?.activeRuntimeEnvironmentId)
getWindowsTerminalCapabilityOwnerKey(settings?.activeRuntimeEnvironmentId),
runtimeTarget
)
const claudeAccountSyncKey = useAppStore((s) => {
const settings = s.settings
@@ -1092,10 +1099,16 @@ function CodexSwitcherMenu({
const inactiveCodexAccounts = useAppStore((s) => s.rateLimits.inactiveCodexAccounts)
const codexTarget = useAppStore((s) => s.rateLimits.codexTarget)
const settings = useAppStore((s) => s.settings)
const hasActiveRuntimeEnvironment = Boolean(settings?.activeRuntimeEnvironmentId?.trim())
const runtimeTarget = useMemo(
() => getActiveRuntimeTarget(settings),
[settings?.activeRuntimeEnvironmentId]
)
const windowsTerminalCapabilities = useWindowsTerminalCapabilities(
navigator.userAgent.includes('Windows'),
navigator.userAgent.includes('Windows') || hasActiveRuntimeEnvironment,
false,
getWindowsTerminalCapabilityOwnerKey(settings?.activeRuntimeEnvironmentId)
getWindowsTerminalCapabilityOwnerKey(settings?.activeRuntimeEnvironmentId),
runtimeTarget
)
const codexAccountSyncKey = useAppStore((s) => {
const settings = s.settings
@@ -37,6 +37,7 @@ import {
getWindowsTerminalCapabilityOwnerKey,
useWindowsTerminalCapabilities
} from '@/lib/windows-terminal-capabilities'
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import {
type BuiltInWindowsTerminalShell,
@@ -248,13 +249,18 @@ function TabBarInner({
const windowsTerminalCapabilityOwnerKey = getWindowsTerminalCapabilityOwnerKey(
activeRuntimeEnvironmentId
)
const runtimeTarget = useMemo(
() => getActiveRuntimeTarget({ activeRuntimeEnvironmentId }),
[activeRuntimeEnvironmentId]
)
const shouldProbeWindowsShellCapabilities =
(isWindows || (isWebClient && activeRuntimeEnvironmentId !== null)) &&
(isWindows || Boolean(activeRuntimeEnvironmentId?.trim()) || isWebClient) &&
!worktreeHasRemoteConnection
const windowsTerminalCapabilities = useWindowsTerminalCapabilities(
shouldProbeWindowsShellCapabilities,
false,
windowsTerminalCapabilityOwnerKey
windowsTerminalCapabilityOwnerKey,
runtimeTarget
)
// Why: SSH-backed PTYs ignore local Windows shell overrides; showing these
// entries there promises PowerShell/CMD/Git Bash but opens the remote shell.
@@ -428,6 +428,63 @@ describe('TabBar PowerShell launch wiring', () => {
expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: WSL')).not.toBeNull()
})
it('uses the active remote host platform to show Windows shell rows in a Mac desktop client', async () => {
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
vi.stubGlobal('__ORCA_WEB_CLIENT__', false)
vi.stubGlobal('window', {
api: {
wsl: {
isAvailable: vi.fn().mockResolvedValue(true),
listDistros: vi.fn().mockResolvedValue(['Ubuntu'])
},
pwsh: { isAvailable: vi.fn().mockResolvedValue(false) },
gitBash: { isAvailable: vi.fn().mockResolvedValue(false) },
runtime: { getStatus: vi.fn().mockResolvedValue({ hostPlatform: 'win32' }) }
}
})
appStoreSnapshot.activeRuntimeEnvironmentId = 'desktop-env-1'
const capabilities = await import('@/lib/windows-terminal-capabilities')
await capabilities.loadWindowsTerminalCapabilities({
force: true,
ownerKey: 'runtime:desktop-env-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 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: PowerShell')
).not.toBeNull()
expect(
findDropdownMenuItemByText(expandNode(element), 'New Terminal: CMD Prompt')
).not.toBeNull()
expect(findDropdownMenuItemByText(expandNode(element), 'New Terminal: WSL')).not.toBeNull()
})
it('shows the Git Bash terminal row when shared Windows capabilities find bash.exe', async () => {
vi.stubGlobal('window', {
api: {
@@ -68,6 +68,10 @@ import { STATS_PANE_SEARCH_ENTRIES } from '@/components/stats/stats-search'
import { EXPERIMENTAL_PANE_SEARCH_ENTRIES } from '@/components/settings/experimental-search'
import { getRepositoryPaneSearchEntries } from '@/components/settings/repository-search'
import { cn } from '@/lib/utils'
import {
getCachedWindowsTerminalCapabilities,
getWindowsTerminalCapabilityOwnerKey
} from '@/lib/windows-terminal-capabilities'
function OrcaLogoSettingsIcon({ className }: LucideProps) {
return createElement('img', {
@@ -88,17 +92,19 @@ export function isWebClientLocation(): boolean {
export function buildSettingsNavigationMetadata({
isMac,
isWindows,
isWindowsTerminalHost = isWindows,
isWebClient,
repos
}: {
isMac: boolean
isWindows: boolean
isWindowsTerminalHost?: boolean
isWebClient: boolean
repos: readonly Repo[]
}): SettingsNavSection[] {
const showDesktopOnlySettings = !isWebClient
const terminalPaneSearchEntries = getTerminalPaneSearchEntries({
isWindows,
isWindows: isWindowsTerminalHost,
isMac
})
const runtimeEnvironmentsSearchEntry = isWebClient
@@ -366,15 +372,31 @@ export function buildSettingsNavigationMetadata({
export function useSettingsNavigationMetadata(): SettingsNavSection[] {
const repos = useAppStore((state) => state.repos)
const activeRuntimeEnvironmentId = useAppStore(
(state) => state.settings?.activeRuntimeEnvironmentId
)
const isMac = isMacUserAgent()
const isWindows = isWindowsUserAgent()
const isWebClient = isWebClientLocation()
const windowsTerminalCapabilityOwnerKey = getWindowsTerminalCapabilityOwnerKey(
activeRuntimeEnvironmentId
)
const isWindowsTerminalHost =
isWindows ||
getCachedWindowsTerminalCapabilities(windowsTerminalCapabilityOwnerKey).hostPlatform === 'win32'
// Why: Settings and Cmd+J share this metadata so platform/runtime visibility
// and search entries cannot drift. Keep this hook free of Settings pane UI
// imports; see docs/reference/cmd-j-settings-actions-plan.md.
return useMemo(
() => buildSettingsNavigationMetadata({ isMac, isWindows, isWebClient, repos }),
[isMac, isWindows, isWebClient, repos]
() =>
buildSettingsNavigationMetadata({
isMac,
isWindows,
isWindowsTerminalHost,
isWebClient,
repos
}),
[isMac, isWindows, isWindowsTerminalHost, isWebClient, repos]
)
}
@@ -197,6 +197,64 @@ describe('windows terminal capabilities', () => {
expect(runtimeGetStatus).toHaveBeenCalledTimes(2)
})
it('loads remote runtime host capabilities through runtime RPC', async () => {
const runtimeEnvironmentCall = vi.fn(async (args: { selector: string; method: string }) => {
const resultByMethod: Record<string, unknown> = {
'status.get': {
hostPlatform: 'win32',
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 2
},
'host.wsl.isAvailable': true,
'host.wsl.listDistros': ['Ubuntu'],
'host.pwsh.isAvailable': true,
'host.gitBash.isAvailable': false
}
return {
id: args.method,
ok: true,
result: resultByMethod[args.method]
}
})
vi.stubGlobal('window', {
api: {
runtimeEnvironments: {
call: runtimeEnvironmentCall
}
}
})
await expect(
loadWindowsTerminalCapabilities({
ownerKey: 'runtime:env-win',
target: { kind: 'environment', environmentId: 'env-win' }
})
).resolves.toEqual({
wslAvailable: true,
wslDistros: ['Ubuntu'],
pwshAvailable: true,
gitBashAvailable: false,
hostPlatform: 'win32',
isLoading: false
})
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
expect.objectContaining({ selector: 'env-win', method: 'host.wsl.isAvailable' })
)
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
expect.objectContaining({ selector: 'env-win', method: 'host.wsl.listDistros' })
)
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
expect.objectContaining({ selector: 'env-win', method: 'host.pwsh.isAvailable' })
)
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
expect.objectContaining({ selector: 'env-win', method: 'host.gitBash.isAvailable' })
)
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
expect.objectContaining({ selector: 'env-win', method: 'status.get' })
)
})
it('prunes expired runtime owner capability caches', async () => {
stubTerminalCapabilityApi({
wslAvailable: false,
@@ -1,4 +1,6 @@
import { useEffect, useState } from 'react'
import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
import type { RuntimeStatus } from '../../../shared/runtime-types'
export type WindowsTerminalCapabilities = {
wslAvailable: boolean
@@ -37,16 +39,15 @@ type WindowsTerminalCapabilityHookState = {
capabilities: WindowsTerminalCapabilities
}
type WindowsTerminalCapabilityLoadTarget = RuntimeClientTarget
export function getWindowsTerminalCapabilityOwnerKey(
activeRuntimeEnvironmentId?: string | null
): string {
const isWebClient = (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ === true
if (!isWebClient) {
return 'local'
}
// Why: paired web clients can switch hosts; Git Bash/WSL availability is
// 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.
return `runtime:${activeRuntimeEnvironmentId?.trim() || 'none'}`
const environmentId = activeRuntimeEnvironmentId?.trim()
return environmentId ? `runtime:${environmentId}` : 'local'
}
function publish(
@@ -99,10 +100,12 @@ export function loadWindowsTerminalCapabilities(
force?: boolean
now?: number
ownerKey?: string
target?: WindowsTerminalCapabilityLoadTarget
} = {}
): Promise<WindowsTerminalCapabilities> {
const now = options.now ?? Date.now()
const ownerKey = options.ownerKey ?? 'local'
const target = options.target ?? { kind: 'local' }
pruneExpiredCapabilityOwners(now)
const cached = cachedCapabilitiesByOwnerKey.get(ownerKey)
if (cached && !options.force && now - cached.loadedAt < CAPABILITY_CACHE_TTL_MS) {
@@ -117,16 +120,7 @@ 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([
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)
])
const nextPendingCapabilities = Promise.all(readWindowsTerminalCapabilityPromises(target))
.then(([wslAvailable, wslDistros, pwshAvailable, gitBashAvailable, hostPlatform]) => {
const capabilities = {
wslAvailable,
@@ -157,9 +151,10 @@ export function loadWindowsTerminalCapabilities(
}
export function refreshWindowsTerminalCapabilities(
ownerKey = 'local'
ownerKey = 'local',
target: WindowsTerminalCapabilityLoadTarget = { kind: 'local' }
): Promise<WindowsTerminalCapabilities> {
return loadWindowsTerminalCapabilities({ force: true, ownerKey })
return loadWindowsTerminalCapabilities({ force: true, ownerKey, target })
}
export function selectWindowsTerminalCapabilitiesForOwner(
@@ -178,8 +173,11 @@ export function selectWindowsTerminalCapabilitiesForOwner(
export function useWindowsTerminalCapabilities(
enabled: boolean,
forceRefreshOnMount = false,
ownerKey = 'local'
ownerKey = 'local',
target: WindowsTerminalCapabilityLoadTarget = { kind: 'local' }
): WindowsTerminalCapabilities {
const targetKind = target.kind
const targetEnvironmentId = target.kind === 'environment' ? target.environmentId : null
const [state, setState] = useState(() => ({
ownerKey,
capabilities: getCachedWindowsTerminalCapabilities(ownerKey)
@@ -190,6 +188,10 @@ export function useWindowsTerminalCapabilities(
setState({ ownerKey, capabilities: UNAVAILABLE_CAPABILITIES })
return
}
const loadTarget: WindowsTerminalCapabilityLoadTarget =
targetKind === 'environment' && targetEnvironmentId
? { kind: 'environment', environmentId: targetEnvironmentId }
: { kind: 'local' }
let cancelled = false
const cached = getCachedWindowsTerminalCapabilities(ownerKey)
@@ -204,13 +206,15 @@ export function useWindowsTerminalCapabilities(
const subscribers = subscribersByOwnerKey.get(ownerKey) ?? new Set()
subscribers.add(setCapabilities)
subscribersByOwnerKey.set(ownerKey, subscribers)
void loadWindowsTerminalCapabilities({ force: forceRefreshOnMount, ownerKey }).then(
(nextCapabilities) => {
if (!cancelled) {
setState({ ownerKey, capabilities: nextCapabilities })
}
void loadWindowsTerminalCapabilities({
force: forceRefreshOnMount,
ownerKey,
target: loadTarget
}).then((nextCapabilities) => {
if (!cancelled) {
setState({ ownerKey, capabilities: nextCapabilities })
}
)
})
return () => {
cancelled = true
@@ -220,11 +224,52 @@ export function useWindowsTerminalCapabilities(
subscribersByOwnerKey.delete(ownerKey)
}
}
}, [enabled, forceRefreshOnMount, ownerKey])
}, [enabled, forceRefreshOnMount, ownerKey, targetKind, targetEnvironmentId])
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)
]
}
export function resetWindowsTerminalCapabilitiesForTests(): void {
cachedCapabilitiesByOwnerKey.clear()
pendingCapabilitiesByOwnerKey.clear()