diff --git a/src/main/cmder.smoke.node-pty.test.ts b/src/main/cmder.smoke.node-pty.test.ts new file mode 100644 index 000000000000..b1990bb22209 --- /dev/null +++ b/src/main/cmder.smoke.node-pty.test.ts @@ -0,0 +1,47 @@ +import * as pty from 'node-pty' +import { describe, expect, it } from 'vitest' +import { applyCmderSpawnEnvironment, resolveCmderRoot } from './cmder' +import { resolveWindowsShellLaunchArgs } from './providers/windows-shell-args' + +// Real ConPTY launch against a locally installed Cmder; skipped when none is found. +describe.runIf(process.platform === 'win32' && resolveCmderRoot() !== null)('Cmder launch', () => { + it('runs init.bat and keeps the requested cwd', async () => { + const root = resolveCmderRoot()! + const cwd = process.cwd() + const launch = resolveWindowsShellLaunchArgs( + 'cmd.exe', + cwd, + cwd, + undefined, + // Why `call`: /K expands %VAR% before init.bat runs; `call` re-expands afterwards. + 'call echo MARK[%^CMDER_ROOT%][%^CMDER_CONFIGURED%][%^CD%]', + undefined, + true + ) + const env: Record = {} + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && key !== 'CMDER_ROOT') { + env[key] = value + } + } + applyCmderSpawnEnvironment(env, root) + const proc = pty.spawn('cmd.exe', launch.shellArgs, { + cwd: launch.effectiveCwd, + env, + cols: 200, + rows: 40 + }) + let out = '' + proc.onData((data) => { + out += data + }) + const exited = new Promise((resolve) => proc.onExit(() => resolve())) + const deadline = Date.now() + 30_000 + while (!out.includes('MARK[C:') && !out.includes(`MARK[${root}`) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 200)) + } + proc.write('exit\r') + await exited + expect(out).toContain(`MARK[${root}][1][${cwd}]`) + }, 60_000) +}) diff --git a/src/main/cmder.test.ts b/src/main/cmder.test.ts new file mode 100644 index 000000000000..829bec0fefb0 --- /dev/null +++ b/src/main/cmder.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { + applyCmderSpawnEnvironment, + applyConfiguredCmderRootEnv, + setConfiguredCmderRoot, + getCmderRootCandidates, + resolveCmderRoot, + resolveWindowsCmderShellRoot +} from './cmder' + +describe('Cmder discovery', () => { + it('prefers CMDER_ROOT and strips quotes and trailing separators', () => { + const candidates = getCmderRootCandidates({ + CMDER_ROOT: '"C:\\programs\\cmder\\"', + USERPROFILE: 'C:\\Users\\alice' + }) + expect(candidates[0]).toBe('C:\\programs\\cmder') + expect(candidates).toContain('C:\\Users\\alice\\scoop\\apps\\cmder\\current') + }) + + it('resolves the first root whose vendor init.bat exists', () => { + expect( + resolveCmderRoot({ + platform: 'win32', + env: { CMDER_ROOT: 'D:\\missing', USERPROFILE: 'C:\\Users\\alice' }, + exists: (path) => path === 'C:\\Users\\alice\\cmder\\vendor\\init.bat' + }) + ).toBe('C:\\Users\\alice\\cmder') + }) + + it('never resolves off Windows', () => { + expect( + resolveCmderRoot({ platform: 'linux', env: { CMDER_ROOT: 'C:\\cmder' }, exists: () => true }) + ).toBeNull() + }) + + it('only maps the cmder sentinel', () => { + const options = { + platform: 'win32' as const, + env: { CMDER_ROOT: 'C:\\cmder' }, + exists: () => true + } + expect(resolveWindowsCmderShellRoot('Cmder', options)).toBe('C:\\cmder') + expect(resolveWindowsCmderShellRoot('cmd.exe', options)).toBeNull() + }) + + it('sets CMDER_ROOT and the init path/quote pair, dropping inherited init state', () => { + const env: Record = { + CMDER_CONFIGURED: '2', + CMDER_INIT_START: '1:00', + CMDER_INIT_END: '1:01' + } + applyCmderSpawnEnvironment(env, 'C:\\Program Files\\cmder') + expect(env).toEqual({ + CMDER_ROOT: 'C:\\Program Files\\cmder', + ORCA_CMDER_INIT: 'C:\\Program Files\\cmder\\vendor\\init.bat', + ORCA_CMDER_INIT_QUOTE: '"' + }) + }) + + it('ranks the spawn-carried setting over CMDER_ROOT, and the main-process setting next', () => { + expect( + getCmderRootCandidates({ ORCA_CMDER_ROOT: 'D:\\cmder', CMDER_ROOT: 'C:\\cmder' }).slice(0, 2) + ).toEqual(['D:\\cmder', 'C:\\cmder']) + setConfiguredCmderRoot(' E:\\apps\\cmder ') + try { + expect(getCmderRootCandidates({ CMDER_ROOT: 'C:\\cmder' })[0]).toBe('E:\\apps\\cmder') + } finally { + setConfiguredCmderRoot(null) + } + }) + + it('carries only a non-empty setting into spawn env', () => { + const options: { env?: Record } = { env: { PATH: 'x' } } + applyConfiguredCmderRootEnv(options, ' ') + expect(options.env).toEqual({ PATH: 'x' }) + applyConfiguredCmderRootEnv(options, 'D:\\cmder') + expect(options.env).toEqual({ PATH: 'x', ORCA_CMDER_ROOT: 'D:\\cmder' }) + applyConfiguredCmderRootEnv(options, '') + expect(options.env).toEqual({ PATH: 'x' }) + }) +}) diff --git a/src/main/cmder.ts b/src/main/cmder.ts new file mode 100644 index 000000000000..e5a5d7676aac --- /dev/null +++ b/src/main/cmder.ts @@ -0,0 +1,136 @@ +import { existsSync } from 'node:fs' +import { win32 as pathWin32 } from 'node:path' +import { WINDOWS_CMDER_SHELL } from '../shared/windows-terminal-shell' + +export const ORCA_CMDER_INIT_ENV = 'ORCA_CMDER_INIT' +export const ORCA_CMDER_INIT_QUOTE_ENV = 'ORCA_CMDER_INIT_QUOTE' +export const ORCA_CMDER_ROOT_ENV = 'ORCA_CMDER_ROOT' + +let configuredCmderRoot: string | null = null + +/** Carries the Cmder setting into the spawn env so the out-of-process daemon resolves the same root. */ +export function applyConfiguredCmderRootEnv( + spawnOptions: { env?: Record }, + configuredRoot: string | undefined +): void { + const root = configuredRoot?.trim() + // Why drop first: a carried key from an earlier setting would outrank CMDER_ROOT after the setting is cleared. + const { [ORCA_CMDER_ROOT_ENV]: _stale, ...env } = spawnOptions.env ?? {} + if (root) { + spawnOptions.env = { ...env, [ORCA_CMDER_ROOT_ENV]: root } + } else if (spawnOptions.env) { + spawnOptions.env = env + } +} + +/** Settings-provided Cmder folder; it outranks CMDER_ROOT and guessed install dirs. */ +export function setConfiguredCmderRoot(root: string | null | undefined): void { + configuredCmderRoot = root?.trim() || null +} + +type CmderRootOptions = { + env?: NodeJS.ProcessEnv + exists?: (path: string) => boolean + platform?: NodeJS.Platform +} + +function readEnv(env: NodeJS.ProcessEnv, names: string[]): string | undefined { + for (const name of names) { + const value = env[name]?.trim() + if (value) { + return value + } + } + return undefined +} + +function stripQuotes(value: string): string { + return value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value +} + +export function getCmderInitScriptPath(root: string): string { + return pathWin32.join(root, 'vendor', 'init.bat') +} + +export function getCmderRootCandidates(env: NodeJS.ProcessEnv = process.env): string[] { + const candidates: string[] = [] + const seen = new Set() + const push = (candidate: string | undefined): void => { + if (!candidate) { + return + } + const normalized = pathWin32.normalize(stripQuotes(candidate)).replace(/\\+$/, '') + const key = normalized.toLowerCase() + if (!seen.has(key)) { + seen.add(key) + candidates.push(normalized) + } + } + + // Why: spawn paths inject the setting as ORCA_CMDER_ROOT so the separate daemon process sees it too. + push(readEnv(env, [ORCA_CMDER_ROOT_ENV]) ?? configuredCmderRoot ?? undefined) + // Why: Cmder's installer and docs both publish CMDER_ROOT; it wins over guessed locations. + push(readEnv(env, ['CMDER_ROOT', 'cmder_root'])) + const userProfile = readEnv(env, ['USERPROFILE', 'UserProfile']) + const systemDrive = readEnv(env, ['SystemDrive', 'SYSTEMDRIVE']) ?? 'C:' + const programRoots = [ + readEnv(env, ['ProgramFiles', 'PROGRAMFILES']), + readEnv(env, ['ProgramFiles(x86)', 'PROGRAMFILES(X86)']), + readEnv(env, ['LOCALAPPDATA', 'LocalAppData']) + ] + for (const root of programRoots) { + if (root) { + push(pathWin32.join(root, 'cmder')) + } + } + if (userProfile) { + push(pathWin32.join(userProfile, 'cmder')) + push(pathWin32.join(userProfile, 'scoop', 'apps', 'cmder', 'current')) + push(pathWin32.join(userProfile, 'scoop', 'apps', 'cmder-full', 'current')) + } + push(pathWin32.join(`${systemDrive}\\`, 'tools', 'cmder')) + push(pathWin32.join(`${systemDrive}\\`, 'cmder')) + return candidates +} + +export function resolveCmderRoot(options: CmderRootOptions = {}): string | null { + const platform = options.platform ?? process.platform + if (platform !== 'win32') { + return null + } + const exists = options.exists ?? existsSync + for (const candidate of getCmderRootCandidates(options.env ?? process.env)) { + if (exists(getCmderInitScriptPath(candidate))) { + return candidate + } + } + return null +} + +export function isCmderAvailable(): boolean { + return resolveCmderRoot() !== null +} + +/** Orca launched from a Cmder console inherits CMDER_CONFIGURED, which makes init.bat skip setup. */ +export function stripInheritedCmderState(env: Record): void { + delete env[ORCA_CMDER_ROOT_ENV] + delete env.CMDER_CONFIGURED + delete env.CMDER_INIT_START + delete env.CMDER_INIT_END +} + +/** Env Cmder's init.bat expects, plus the path/quote pair the cmd `/K` chain expands. */ +export function applyCmderSpawnEnvironment(env: Record, cmderRoot: string): void { + stripInheritedCmderState(env) + env.CMDER_ROOT = cmderRoot + env[ORCA_CMDER_INIT_ENV] = getCmderInitScriptPath(cmderRoot) + env[ORCA_CMDER_INIT_QUOTE_ENV] = '"' +} + +/** Cmder root for a requested shell, or null when the request is not Cmder (or Cmder is missing). */ +export function resolveWindowsCmderShellRoot( + shell: string | undefined, + options: CmderRootOptions = {} +): string | null { + return shell?.trim().toLowerCase() === WINDOWS_CMDER_SHELL ? resolveCmderRoot(options) : null +} diff --git a/src/main/daemon/pty-subprocess/shell-launch-plan.ts b/src/main/daemon/pty-subprocess/shell-launch-plan.ts index 073cdc867c77..82e2e41e716c 100644 --- a/src/main/daemon/pty-subprocess/shell-launch-plan.ts +++ b/src/main/daemon/pty-subprocess/shell-launch-plan.ts @@ -2,6 +2,7 @@ import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startu import { win32 as pathWin32 } from 'node:path' import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../../git-bash' import { isPwshAvailable } from '../../pwsh' +import { applyCmderSpawnEnvironment, resolveWindowsCmderShellRoot } from '../../cmder' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../../pty/codex-home-wsl-env' import { addOrcaWslInteropEnv } from '../../pty/wsl-orca-env' import { @@ -34,7 +35,7 @@ import { type RecognizedAgentProcess } from '../../../shared/agent-process-recognition' import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../../shared/hermes-startup-query' -import { WINDOWS_GIT_BASH_SHELL } from '../../../shared/windows-terminal-shell' +import { WINDOWS_CMDER_SHELL, WINDOWS_GIT_BASH_SHELL } from '../../../shared/windows-terminal-shell' import { getShellLaunchConfig, resolvePtyShellPath } from '../shell-ready' import { resolveWslSessionContext } from '../wsl-session-context' import { finalizeDaemonPtyEnvironment, rescrubDaemonPtyEnvironment } from './spawn-environment' @@ -70,6 +71,7 @@ export function createPtyShellLaunchPlan( if (process.platform === 'win32') { const normalizedShellFamily = pathWin32.basename(shellPath).toLowerCase() const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellPath) + const cmderRoot = resolveWindowsCmderShellRoot(shellPath, { env }) const resolvedShellFamily: WindowsPowerShellShellFamily = normalizedShellFamily === 'powershell.exe' || normalizedShellFamily === 'pwsh.exe' ? normalizedShellFamily @@ -87,6 +89,9 @@ export function createPtyShellLaunchPlan( shellPath = resolvedGitBashPath } else if (shellPath === WINDOWS_GIT_BASH_SHELL) { shellPath = 'powershell.exe' + } else if (shellPath.toLowerCase() === WINDOWS_CMDER_SHELL) { + // Why: Cmder is cmd.exe plus init.bat; a missing install degrades to plain cmd, not a spawn failure. + shellPath = 'cmd.exe' } else { shellPath = shouldResolvePowerShellFamily ? (resolveEffectiveWindowsPowerShell({ @@ -102,6 +107,9 @@ export function createPtyShellLaunchPlan( ) { env[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"' } + if (cmderRoot) { + applyCmderSpawnEnvironment(env, cmderRoot) + } windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({ shellPath, cwd: spawnCwd, @@ -123,7 +131,8 @@ export function createPtyShellLaunchPlan( resolveSafePtyDefaultCwd(), resolvedWslContext, opts.command, - env.ORCA_CODEX_LAUNCH_PREFLIGHT + env.ORCA_CODEX_LAUNCH_PREFLIGHT, + cmderRoot !== null ) shellArgs = resolved.shellArgs spawnCwd = resolved.effectiveCwd diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index d056727fdb71..0a2b8c528166 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -13,6 +13,7 @@ import { getDevInstanceIdentity } from '../startup/dev-instance-identity' import { isPwshAvailableAsync } from '../pwsh' import { isWslAvailableAsync, listWslDistrosAsync } from '../wsl' import { isGitBashAvailable } from '../git-bash' +import { isCmderAvailable } from '../cmder' import { setUnreadDockBadgeCount } from '../dock/unread-badge' import { destroySystemTray } from '../tray/system-tray' import { @@ -277,6 +278,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp ipcMain.handle('wsl:listDistros', (): Promise => listWslDistrosAsync()) ipcMain.handle('pwsh:isAvailable', (): Promise => isPwshAvailableAsync()) ipcMain.handle('gitBash:isAvailable', (): boolean => isGitBashAvailable()) + ipcMain.handle('cmder:isAvailable', (): boolean => isCmderAvailable()) // The selected IME identity must win over its US-shaped backing keyboard layout. ipcMain.handle('app:getKeyboardInputSourceId', async (): Promise => { diff --git a/src/main/ipc/preflight-remote-windows-terminal-capabilities.ts b/src/main/ipc/preflight-remote-windows-terminal-capabilities.ts index 6ac63d39c5a9..fe2b9397f090 100644 --- a/src/main/ipc/preflight-remote-windows-terminal-capabilities.ts +++ b/src/main/ipc/preflight-remote-windows-terminal-capabilities.ts @@ -5,6 +5,8 @@ export type RemoteWindowsTerminalCapabilities = { wslDistros: string[] pwshAvailable: boolean gitBashAvailable: boolean + // Why optional: relays predating Cmder omit it, which means no Cmder. + cmderAvailable?: boolean hostPlatform: NodeJS.Platform | null } diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index 8adec688a678..5bb9513f35a8 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -28,6 +28,7 @@ import { planCodexNoDaemonLaunch } from '../../../pty/codex-no-daemon-launch-com import type { PtyIpcSpawnState } from './spawn-state' import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' import { prepareOpenCodePtyLaunch } from '../../../opencode/opencode-pty-launch' +import { applyConfiguredCmderRootEnv } from '../../../cmder' /** Carries deletions to provider-owned environments, including persistent older daemons. */ export async function buildPtyIpcSpawnOptions( @@ -175,6 +176,10 @@ export async function buildPtyIpcSpawnOptions( }) } if (process.platform === 'win32' && !args.connectionId) { + applyConfiguredCmderRootEnv( + ctx.spawnOptions, + ctx.deps.getSettings?.()?.terminalWindowsCmderPath + ) // Why: the renderer models PowerShell as one shell family; thread the implementation choice so both PTY paths resolve the same executable. ctx.spawnOptions.terminalWindowsWslDistro = ctx.expectedWslDistro ctx.spawnOptions.terminalWindowsPowerShellImplementation = ctx.deps.getSettings diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 26c3856b45ee..2d651f274047 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -36,6 +36,7 @@ import type { RuntimePtySpawnState } from './spawn-state' import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' import { prepareAntigravityAccountForLaunch } from '../../../antigravity/native-account-launch' import { prepareOpenCodePtyLaunch } from '../../../opencode/opencode-pty-launch' +import { applyConfiguredCmderRootEnv } from '../../../cmder' /** Headless spawns need the same host-side environment isolation as desktop spawns. */ export async function buildRuntimePtySpawnOptions( @@ -206,6 +207,12 @@ export async function buildRuntimePtySpawnOptions( settings: ctx.deps.getSettings?.() }) ctx.spawnOptions.terminalWindowsWslDistro = ctx.expectedWslDistro + if (process.platform === 'win32') { + applyConfiguredCmderRootEnv( + ctx.spawnOptions, + ctx.deps.getSettings?.()?.terminalWindowsCmderPath + ) + } ctx.spawnOptions.terminalWindowsPowerShellImplementation = ctx.deps.getSettings ? (ctx.deps.getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') : undefined diff --git a/src/main/opencode/opencode-pty-launch.test.ts b/src/main/opencode/opencode-pty-launch.test.ts index 21ec4ee0936e..d2068b091751 100644 --- a/src/main/opencode/opencode-pty-launch.test.ts +++ b/src/main/opencode/opencode-pty-launch.test.ts @@ -31,7 +31,8 @@ const plan: LocalPtyLaunchPlan = { getFallbackShellReadyConfig: undefined, primaryPreLaunchEnv: {}, isWslShell: false, - launchWslDistro: null + launchWslDistro: null, + cmderRoot: null } const hookServer = vi.hoisted(() => { diff --git a/src/main/providers/local-pty-launch-plan.ts b/src/main/providers/local-pty-launch-plan.ts index 455cee3c4653..275f1b9a27ec 100644 --- a/src/main/providers/local-pty-launch-plan.ts +++ b/src/main/providers/local-pty-launch-plan.ts @@ -1,7 +1,8 @@ import { win32 as pathWin32 } from 'node:path' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' -import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' +import { WINDOWS_CMDER_SHELL, WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' import { resolveWindowsGitBashShellPath } from '../git-bash' +import { resolveWindowsCmderShellRoot } from '../cmder' import { getDefaultWslDistro, parseWslPath } from '../wsl' import { getDefaultCwd, @@ -56,6 +57,8 @@ export type LocalPtyLaunchPlan = { primaryPreLaunchEnv: Record isWslShell: boolean launchWslDistro: string | null + /** Set when the pane is cmd.exe bootstrapped through Cmder's init.bat. */ + cmderRoot: string | null } export class DeferredLocalPtyLaunchPlan { @@ -74,6 +77,7 @@ function finalizeLocalPtyLaunchPlan( validationCwd: string startupCommandDeliveredInShellArgs?: boolean windowsFallbackAttempts?: ReturnType + cmderRoot?: string | null } ): LocalPtyLaunchPlan { ensureNodePtySpawnHelperExecutable() @@ -100,7 +104,8 @@ function finalizeLocalPtyLaunchPlan( getFallbackShellReadyConfig: undefined, primaryPreLaunchEnv: {}, isWslShell, - launchWslDistro: isWslShell ? (seed.launchWslContext?.distro ?? null) : null + launchWslDistro: isWslShell ? (seed.launchWslContext?.distro ?? null) : null, + cmderRoot: shell.cmderRoot ?? null } } @@ -121,6 +126,10 @@ function createWindowsLocalPtyLaunchPlan( } const normalizedShellFamily = pathWin32.basename(shellFamily).toLowerCase() const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellFamily) + // Why args.env: orcad's in-process fallback never sets configuredCmderRoot; the setting rides in as ORCA_CMDER_ROOT. + const cmderRoot = resolveWindowsCmderShellRoot(shellFamily, { + env: { ...process.env, ...args.env } + }) // Why: normalize setting-value and path forms to the PowerShell family so the resolver can fall back to inbox powershell.exe. const powerShellImplementation = getOptions().getWindowsPowerShellImplementation?.() const resolvedShellFamily: WindowsPowerShellShellFamily = @@ -141,6 +150,9 @@ function createWindowsLocalPtyLaunchPlan( shellPath = resolvedGitBashPath } else if (shellFamily === WINDOWS_GIT_BASH_SHELL) { shellPath = 'powershell.exe' + } else if (shellFamily.toLowerCase() === WINDOWS_CMDER_SHELL) { + // Why: Cmder is cmd.exe plus init.bat; a missing install degrades to plain cmd, not a spawn failure. + shellPath = 'cmd.exe' } else { shellPath = shouldResolvePowerShellFamily ? (resolveEffectiveWindowsPowerShell({ @@ -174,7 +186,9 @@ function createWindowsLocalPtyLaunchPlan( cwd, defaultCwd, seed.launchWslContext, - args.command + args.command, + undefined, + cmderRoot !== null ) return finalizeLocalPtyLaunchPlan(seed, { shellPath, @@ -182,7 +196,8 @@ function createWindowsLocalPtyLaunchPlan( effectiveCwd: resolved.effectiveCwd, validationCwd: resolved.validationCwd, startupCommandDeliveredInShellArgs: resolved.startupCommandDeliveredInShellArgs === true, - windowsFallbackAttempts + windowsFallbackAttempts, + cmderRoot }) } return shouldProbePwsh diff --git a/src/main/providers/local-pty-pi-owner-env.test.ts b/src/main/providers/local-pty-pi-owner-env.test.ts index 4b8643b7bc56..b28ac87e829f 100644 --- a/src/main/providers/local-pty-pi-owner-env.test.ts +++ b/src/main/providers/local-pty-pi-owner-env.test.ts @@ -34,7 +34,8 @@ const plan: LocalPtyLaunchPlan = { getFallbackShellReadyConfig: undefined, primaryPreLaunchEnv: {}, isWslShell: false, - launchWslDistro: null + launchWslDistro: null, + cmderRoot: null } afterEach(() => vi.unstubAllEnvs()) diff --git a/src/main/providers/local-pty-provider-windows-shell-launch.test.ts b/src/main/providers/local-pty-provider-windows-shell-launch.test.ts index bc96cdb1d6af..4456e47dd97a 100644 --- a/src/main/providers/local-pty-provider-windows-shell-launch.test.ts +++ b/src/main/providers/local-pty-provider-windows-shell-launch.test.ts @@ -631,6 +631,87 @@ describe('LocalPtyProvider', () => { ) }) + it('launches Cmder as cmd.exe that calls init.bat before the Codex preflight', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const originalCmderRoot = process.env.CMDER_ROOT + const existsFallback = existsSyncMock.getMockImplementation() + Object.defineProperty(process, 'platform', { value: 'win32' }) + process.env.CMDER_ROOT = 'C:\\tools\\cmder' + existsSyncMock.mockImplementation((path: string) => + path === 'C:\\tools\\cmder\\vendor\\init.bat' ? true : (existsFallback?.(path) ?? false) + ) + provider.configure({ + getWindowsShell: () => 'cmder', + buildSpawnEnv: (_id, env) => ({ + ...env, + ORCA_CODEX_LAUNCH_PREFLIGHT: CODEX_LAUNCH_PREFLIGHT + }) + }) + + try { + await provider.spawn({ cols: 80, rows: 24, cwd: 'C:\\Users\\jin\\repo' }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + if (originalCmderRoot === undefined) { + delete process.env.CMDER_ROOT + } else { + process.env.CMDER_ROOT = originalCmderRoot + } + } + + expect(spawnMock).toHaveBeenCalledWith( + 'cmd.exe', + [ + '/K', + 'chcp 65001 > nul & call %ORCA_CMDER_INIT_QUOTE%%ORCA_CMDER_INIT%%ORCA_CMDER_INIT_QUOTE% & if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE%%ORCA_CODEX_LAUNCH_PREFLIGHT%%ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE% agent hooks prepare-codex > nul 2>&1' + ], + expect.objectContaining({ + cwd: 'C:\\Users\\jin\\repo', + env: expect.objectContaining({ + CMDER_ROOT: 'C:\\tools\\cmder', + ORCA_CMDER_INIT: 'C:\\tools\\cmder\\vendor\\init.bat', + ORCA_CMDER_INIT_QUOTE: '"' + }) + }) + ) + }) + + it('resolves Cmder from the spawn-carried ORCA_CMDER_ROOT on the in-process fallback', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const existsFallback = existsSyncMock.getMockImplementation() + Object.defineProperty(process, 'platform', { value: 'win32' }) + existsSyncMock.mockImplementation((path: string) => + path === 'D:\\apps\\cmder\\vendor\\init.bat' ? true : (existsFallback?.(path) ?? false) + ) + provider.configure({ getWindowsShell: () => 'cmder' }) + + try { + await provider.spawn({ + cols: 80, + rows: 24, + cwd: 'C:\\Users\\jin\\repo', + env: { ORCA_CMDER_ROOT: 'D:\\apps\\cmder' } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).toHaveBeenCalledWith( + 'cmd.exe', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ + CMDER_ROOT: 'D:\\apps\\cmder', + ORCA_CMDER_INIT: 'D:\\apps\\cmder\\vendor\\init.bat' + }) + }) + ) + }) + it('runs the Codex preflight once in the cmd.exe startup chain', async () => { const platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { value: 'win32' }) diff --git a/src/main/providers/local-pty-windows-spawn-environment.ts b/src/main/providers/local-pty-windows-spawn-environment.ts index f7967d710f78..0edf2215bc5c 100644 --- a/src/main/providers/local-pty-windows-spawn-environment.ts +++ b/src/main/providers/local-pty-windows-spawn-environment.ts @@ -5,6 +5,7 @@ import { addWslEnvKeys } from '../wsl-env' import { parseWslPath } from '../wsl' import { isWindowsGitBashShellPath } from '../git-bash' import type { LocalPtyLaunchPlan } from './local-pty-launch-plan' +import { applyCmderSpawnEnvironment } from '../cmder' import { ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV, resolveWindowsShellLaunchArgs @@ -65,6 +66,9 @@ export function finalizeWindowsLocalPtySpawnEnvironment(args: { delete env.ORCA_CODEX_HOME } + if (plan.cmderRoot) { + applyCmderSpawnEnvironment(env, plan.cmderRoot) + } const shellBasename = pathWin32.basename(plan.shellPath).toLowerCase() const codexLaunchPreflightCommand = env.ORCA_CODEX_LAUNCH_PREFLIGHT if ( @@ -81,7 +85,8 @@ export function finalizeWindowsLocalPtySpawnEnvironment(args: { plan.defaultCwd, plan.launchWslContext, spawn.command, - codexLaunchPreflightCommand + codexLaunchPreflightCommand, + plan.cmderRoot !== null ) plan.shellArgs = resolved.shellArgs plan.effectiveCwd = resolved.effectiveCwd diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 38352a1b6d48..16625620d08d 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -72,6 +72,23 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(result.validationCwd).toBe('C:\\Users\\alice') }) + it('calls Cmder init.bat through env-expanded quotes before the startup command', () => { + const result = resolveWindowsShellLaunchArgs( + 'cmd.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice', + undefined, + 'codex', + undefined, + true + ) + expect(result.shellArgs).toEqual([ + '/K', + 'chcp 65001 > nul & call %ORCA_CMDER_INIT_QUOTE%%ORCA_CMDER_INIT%%ORCA_CMDER_INIT_QUOTE% & codex' + ]) + expect(result.startupCommandDeliveredInShellArgs).toBe(true) + }) + it('embeds short cmd.exe startup commands in shell args', () => { const result = resolveWindowsShellLaunchArgs( 'cmd.exe', diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index c5ceade00a44..518b7bc5ec3f 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -1,5 +1,6 @@ import { win32 as pathWin32 } from 'node:path' import { isWindowsGitBashShellPath } from '../git-bash' +import { ORCA_CMDER_INIT_ENV, ORCA_CMDER_INIT_QUOTE_ENV } from '../cmder' import { parseWslPath, toLinuxPath, toWindowsWslPath } from '../wsl' import { buildWslExecArgs, @@ -20,6 +21,9 @@ const STARTUP_COMMAND_TEXT_MAX_CHARS = 6000 const POWERSHELL_ENCODED_COMMAND_ARG_MAX_CHARS = 28_000 const CMD_UTF8_SETUP_COMMAND = 'chcp 65001 > nul' export const ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV = 'ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE' +// Why: node-pty backslash-escapes literal argv quotes, which cmd.exe does not understand, so the +// quote around a spaced Cmder path is expanded from an env var inside cmd.exe instead. +const CMD_CMDER_INIT = `call %${ORCA_CMDER_INIT_QUOTE_ENV}%%${ORCA_CMDER_INIT_ENV}%%${ORCA_CMDER_INIT_QUOTE_ENV}%` const CMD_CODEX_LAUNCH_PREFLIGHT = `if defined ORCA_CODEX_LAUNCH_PREFLIGHT call %${ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV}%%ORCA_CODEX_LAUNCH_PREFLIGHT%%${ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV}% agent hooks prepare-codex > nul 2>&1` // Why: Git for Windows' bash inherits the ConPTY console's OEM code page // (CP437), so a TUI that writes UTF-8 bytes straight to the console — agents @@ -167,7 +171,8 @@ export function normalizeWindowsTerminalCwd(cwd: string): string { /** Build the argv + effective cwd for a Windows shell launch. * - * - cmd.exe: `/K chcp 65001 > nul` so multi-byte CJK output renders correctly. + * - cmd.exe: `/K chcp 65001 > nul` so multi-byte CJK output renders correctly; + * `cmderInit` also calls Cmder's init.bat (path supplied via ORCA_CMDER_INIT). * - powershell.exe / pwsh.exe: dot-source $PROFILE and force UTF-8 I/O so * oh-my-posh / starship / PSReadLine keep working. `-NoExit` alone would * skip the profile. @@ -180,7 +185,8 @@ export function resolveWindowsShellLaunchArgs( defaultCwd: string, wslContext?: WindowsShellWslContext, startupCommand?: string, - codexLaunchPreflightCommand?: string + codexLaunchPreflightCommand?: string, + cmderInit?: boolean ): WindowsShellLaunchArgs { const shellBasename = pathWin32.basename(shellPath).toLowerCase() const nativeCwd = normalizeWindowsTerminalCwd(cwd) @@ -189,6 +195,7 @@ export function resolveWindowsShellLaunchArgs( const shellArgStartupCommand = getCmdShellArgStartupCommand(startupCommand) const startupCommands = [ CMD_UTF8_SETUP_COMMAND, + ...(cmderInit ? [CMD_CMDER_INIT] : []), ...(codexLaunchPreflightCommand ? [CMD_CODEX_LAUNCH_PREFLIGHT] : []), ...(shellArgStartupCommand ? [shellArgStartupCommand] : []) ] diff --git a/src/main/runtime/rpc/methods/host-capabilities.ts b/src/main/runtime/rpc/methods/host-capabilities.ts index 85a32fd1e5c2..b8985c71cc6d 100644 --- a/src/main/runtime/rpc/methods/host-capabilities.ts +++ b/src/main/runtime/rpc/methods/host-capabilities.ts @@ -2,6 +2,7 @@ import { defineMethod } from '../core' import { isPwshAvailableAsync } from '../../../pwsh' import { isWslAvailableAsync, listWslDistrosAsync } from '../../../wsl' import { isGitBashAvailable } from '../../../git-bash' +import { isCmderAvailable } from '../../../cmder' export const HOST_CAPABILITY_METHODS = [ defineMethod({ @@ -30,5 +31,10 @@ export const HOST_CAPABILITY_METHODS = [ name: 'host.gitBash.isAvailable', params: null, handler: async () => isGitBashAvailable() + }), + defineMethod({ + name: 'host.cmder.isAvailable', + params: null, + handler: async () => isCmderAvailable() }) ] diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index d544dc6720b0..a34d95f3ac80 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -139,6 +139,7 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'gitlab.updateMRState', 'gitlab.workItemDetails', 'host.gitBash.isAvailable', + 'host.cmder.isAvailable', 'host.platform', 'host.pwsh.isAvailable', 'host.wsl.isAvailable', diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index c9ec3b30368d..b49bd7d31b22 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -52,6 +52,7 @@ import { createWslCliReconciliationStartupBarrier } from './wsl-cli-reconciliati import { agentHookServer } from '../agent-hooks/server' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { reportProfileStateWriteFailure } from './profile-state-write-failure' +import { setConfiguredCmderRoot } from '../cmder' export async function initializeReadyFoundation(): Promise { logStartupMilestone('app-ready') @@ -216,7 +217,11 @@ export async function initializeReadyFoundation(): Promise { ) // Why: apply initial fallback WSL distro from store settings for global git/CLI calls. setDefaultWslDistroOverride(store.getSettings().terminalWindowsWslDistro ?? null) + setConfiguredCmderRoot(store.getSettings().terminalWindowsCmderPath) store.onSettingsChanged((updates, settings) => { + if ('terminalWindowsCmderPath' in updates) { + setConfiguredCmderRoot(settings.terminalWindowsCmderPath) + } if ('electronHttp1CompatibilityMode' in updates) { writeHttp1CompatibilityMarker( canonicalUserDataPath, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index cf5060fab898..a53c54c81ef7 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -156,6 +156,7 @@ export type PreloadApi = { wsl: RuntimeApi['wsl'] pwsh: RuntimeApi['pwsh'] gitBash: RuntimeApi['gitBash'] + cmder: RuntimeApi['cmder'] plugins: PluginsApi agentStatus: AgentStatusApi mobile: MobileApi diff --git a/src/preload/api/git-bash-bridge.ts b/src/preload/api/git-bash-bridge.ts index c2186d12aa3c..63aaf06609e3 100644 --- a/src/preload/api/git-bash-bridge.ts +++ b/src/preload/api/git-bash-bridge.ts @@ -4,3 +4,7 @@ import type { PreloadApi } from '../api-types' export const gitBashApi = { isAvailable: (): Promise => ipcRenderer.invoke('gitBash:isAvailable') } satisfies PreloadApi['gitBash'] + +export const cmderApi = { + isAvailable: (): Promise => ipcRenderer.invoke('cmder:isAvailable') +} satisfies PreloadApi['cmder'] diff --git a/src/preload/api/preflight-api.ts b/src/preload/api/preflight-api.ts index d8582af13a74..bbc8bba15497 100644 --- a/src/preload/api/preflight-api.ts +++ b/src/preload/api/preflight-api.ts @@ -56,6 +56,7 @@ export type PreflightApi = { wslDistros: string[] pwshAvailable: boolean gitBashAvailable: boolean + cmderAvailable?: boolean hostPlatform: NodeJS.Platform | null }> } diff --git a/src/preload/api/runtime-api.ts b/src/preload/api/runtime-api.ts index f158699ee47b..52d9b58d179f 100644 --- a/src/preload/api/runtime-api.ts +++ b/src/preload/api/runtime-api.ts @@ -154,4 +154,7 @@ export type RuntimeApi = { gitBash: { isAvailable: () => Promise } + cmder: { + isAvailable: () => Promise + } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 5f64a1e2e351..4098e4d1ff1c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,7 +9,7 @@ import { orcaProfilesApi } from './api/orca-profiles-bridge' import { platformApi } from './api/platform-bridge' import { wslApi } from './api/wsl-bridge' import { pwshApi } from './api/pwsh-bridge' -import { gitBashApi } from './api/git-bash-bridge' +import { cmderApi, gitBashApi } from './api/git-bash-bridge' import { pluginsApi } from './api/plugins-bridge' import { reposApi } from './api/repos-bridge' import { projectsApi } from './api/projects-bridge' @@ -107,6 +107,7 @@ const api = { wsl: wslApi, pwsh: pwshApi, gitBash: gitBashApi, + cmder: cmderApi, plugins: pluginsApi, repos: reposApi, projects: projectsApi, diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index b5d3cc73936a..8e007f86cf46 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -10,12 +10,14 @@ const { isPwshAvailableAsyncMock, isWslAvailableAsyncMock, listWslDistrosAsyncMock, - isGitBashAvailableMock + isGitBashAvailableMock, + isCmderAvailableMock } = vi.hoisted(() => ({ isPwshAvailableAsyncMock: vi.fn(), isWslAvailableAsyncMock: vi.fn(), listWslDistrosAsyncMock: vi.fn(), - isGitBashAvailableMock: vi.fn() + isGitBashAvailableMock: vi.fn(), + isCmderAvailableMock: vi.fn() })) vi.mock('child_process', () => { @@ -31,6 +33,7 @@ vi.mock('../main/wsl', () => ({ listWslDistrosAsync: listWslDistrosAsyncMock })) vi.mock('../main/git-bash', () => ({ isGitBashAvailable: isGitBashAvailableMock })) +vi.mock('../main/cmder', () => ({ isCmderAvailable: isCmderAvailableMock })) vi.mock('../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) import { @@ -71,6 +74,7 @@ beforeEach(() => { isWslAvailableAsyncMock.mockReset() listWslDistrosAsyncMock.mockReset() isGitBashAvailableMock.mockReset() + isCmderAvailableMock.mockReset() }) describe('buildCommandLookupSpecs', () => { @@ -315,6 +319,7 @@ describe('PreflightHandler', () => { listWslDistrosAsyncMock.mockResolvedValue(['Ubuntu']) isPwshAvailableAsyncMock.mockResolvedValue(true) isGitBashAvailableMock.mockReturnValue(true) + isCmderAvailableMock.mockReturnValue(true) const requestHandlers = new Map) => Promise>() const dispatcher = { @@ -335,6 +340,7 @@ describe('PreflightHandler', () => { wslDistros: ['Ubuntu'], pwshAvailable: true, gitBashAvailable: true, + cmderAvailable: true, hostPlatform: 'win32' }) } finally { diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index a98a32af3a83..9317ca767359 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -6,7 +6,9 @@ import type { RelayDispatcher } from './dispatcher' import { buildRelayCommandEnv } from './relay-command-env' import { isPwshAvailableAsync } from '../main/pwsh' import { isWslAvailableAsync, listWslDistrosAsync } from '../main/wsl' +import { isCmderAvailable } from '../main/cmder' import { isGitBashAvailable } from '../main/git-bash' +import type { RemoteWindowsTerminalCapabilities } from '../main/ipc/preflight-remote-windows-terminal-capabilities' import { buildPosixCommandPathLookupScript } from '../shared/posix-command-path-lookup' import { runProcess } from '../shared/child-process/run-process' @@ -109,13 +111,7 @@ export class PreflightHandler { } } - private async detectWindowsTerminalCapabilities(): Promise<{ - wslAvailable: boolean - wslDistros: string[] - pwshAvailable: boolean - gitBashAvailable: boolean - hostPlatform: NodeJS.Platform | null - }> { + private async detectWindowsTerminalCapabilities(): Promise { const [wslAvailable, pwshAvailable, gitBashAvailable] = await Promise.all([ isWslAvailableAsync().catch(() => false), isPwshAvailableAsync().catch(() => false), @@ -127,6 +123,7 @@ export class PreflightHandler { wslDistros, pwshAvailable, gitBashAvailable, + cmderAvailable: isCmderAvailable(), hostPlatform: process.platform } } diff --git a/src/relay/pty-handler-revive.test.ts b/src/relay/pty-handler-revive.test.ts index 88155e382421..5dbf773e74f7 100644 --- a/src/relay/pty-handler-revive.test.ts +++ b/src/relay/pty-handler-revive.test.ts @@ -1,6 +1,6 @@ import './mock-descendant-sweep' import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { existsSync, rmSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { hashWorktreeId } from '../main/terminal-history-id' @@ -741,4 +741,49 @@ describe('PtyHandler', () => { expect(mockPtySpawn.mock.calls.map(([shell]) => shell)).not.toContain('nc.exe') }) }) + + // Why real win32: Cmder discovery checks vendor\init.bat on disk with win32 paths. + it.skipIf(process.platform !== 'win32')( + 'revives a Cmder pane with the root its spawn carried', + async () => { + const cmderRoot = mkdtempSync(join(tmpdir(), 'relay-revive-cmder-')) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + mkdirSync(join(cmderRoot, 'vendor')) + writeFileSync(join(cmderRoot, 'vendor', 'init.bat'), '@echo off') + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + cwd: LIVE_CWD, + shellOverride: 'cmder', + env: { CMDER_ROOT: cmderRoot } + }) + const state = (await dispatcher.callRequest('pty.serialize', { ids: [PTY_1] })) as string + expect(JSON.parse(state)[0]).toMatchObject({ cmderRoot }) + + await handler.dispose({ waitForPhysicalExit: false }) + mockPtySpawn.mockClear() + dispatcher = createMockDispatcher() + handler = createTestPtyHandler(dispatcher) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + await dispatcher.callRequest('pty.revive', { state }) + } finally { + killSpy.mockRestore() + } + + expect(mockPtySpawn).toHaveBeenCalledWith( + 'cmd.exe', + expect.arrayContaining(['/K']), + expect.objectContaining({ + env: expect.objectContaining({ + ORCA_CMDER_INIT: join(cmderRoot, 'vendor', 'init.bat') + }) + }) + ) + } finally { + rmSync(cmderRoot, { recursive: true, force: true }) + } + } + ) }) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index d107d249d1aa..903629d5c54c 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -30,7 +30,12 @@ import { listShellProfiles } from './pty-shell-utils' import { inspectPtyChildProcesses, processHasChildren } from './pty-child-process-inspection' -import { getRelayShellLaunchConfig, isRelayWslShell } from './pty-shell-launch' +import { + getRelayShellLaunchConfig, + isRelayWslShell, + resolveRelaySpawnExecutable +} from './pty-shell-launch' +import { stripInheritedCmderState } from '../main/cmder' import { RetiredPaneSurfaceRegistry } from './retired-pane-surfaces' import { applyScrubSafeAgentEnvAliases } from '../shared/agent-hook-scrub-safe-env' import { addWslEnvKeys } from '../shared/wsl-env' @@ -267,6 +272,8 @@ type ManagedPty = { shellPath?: string /** The raw client-requested shell override, kept so revive can re-resolve it on this host. */ shellOverride?: string + /** Cmder root this pane launched with, so revive keeps init.bat when CMDER_ROOT came only with the spawn. */ + cmderRoot?: string /** Requested WSL distro; only meaningful when the override launched wsl.exe. */ wslDistro?: string shellCwd?: string @@ -497,6 +504,7 @@ type SerializedPtyEntry = { * default shell, which is what those relays did anyway. */ shellOverride?: string + cmderRoot?: string terminalWindowsWslDistro?: string agentSessionOwners?: AgentSessionOwnerBinding[] } @@ -2125,6 +2133,9 @@ export class PtyHandler { }) const managedStartupCommand = shouldProviderDeliverCommand ? command : launchCommandHint // Why: both renderer- and provider-delivered startup commands use this marker; the delivering side strips it from output. + if (resolveRelaySpawnExecutable(shell) !== shell) { + stripInheritedCmderState(spawnEnv) + } const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, { terminalWindowsWslDistro, emitReadyMarker: shouldEmitShellReadyMarker, @@ -2150,7 +2161,7 @@ export class PtyHandler { } let term: IPty try { - term = pty.spawn(shell, shellLaunch.args, { + term = pty.spawn(resolveRelaySpawnExecutable(shell), shellLaunch.args, { // Why: node-pty overwrites env.TERM with `name`; pass caller-selected TERM so it isn't lost. name: spawnEnv.TERM ?? 'xterm-256color', cols, @@ -2210,6 +2221,7 @@ export class PtyHandler { // Why the resolved one gates it: on a POSIX relay an override is rejected // outright, and storing one revive would only reject again is noise. ...(resolvedShellOverride ? { shellOverride } : {}), + ...(shellLaunch.env.CMDER_ROOT ? { cmderRoot: shellLaunch.env.CMDER_ROOT } : {}), ...(terminalWindowsWslDistro ? { wslDistro: terminalWindowsWslDistro } : {}), shellCwd: cwd, shellPathEnv: spawnEnv.PATH, @@ -3179,6 +3191,7 @@ export class PtyHandler { // Why serialized: revive re-spawns the shell, and without these a WSL // pane came back as the host default shell in another distro's history. ...(managed.shellOverride ? { shellOverride: managed.shellOverride } : {}), + ...(managed.cmderRoot ? { cmderRoot: managed.cmderRoot } : {}), ...(managed.wslDistro ? { terminalWindowsWslDistro: managed.wslDistro } : {}), ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}) }) @@ -3240,6 +3253,10 @@ export class PtyHandler { if (explicitTerm !== undefined) { revivedEnv.TERM = explicitTerm } + // Why: only honoured when that root still has vendor\init.bat; otherwise the pane is plain cmd. + if (typeof entry.cmderRoot === 'string' && entry.cmderRoot.trim()) { + revivedEnv.CMDER_ROOT = entry.cmderRoot.trim() + } // Why: serialized state may come from an older/untrusted client; reapply fresh-spawn bounds. const envToDelete = sanitizeEnvToDelete(entry.envToDelete) const shellOverride = typeof entry.shellOverride === 'string' ? entry.shellOverride.trim() : '' @@ -3293,6 +3310,9 @@ export class PtyHandler { if (gitCredentialPromptGuarded) { Object.assign(spawnEnv, gitCredentialPromptGuardEnv(spawnEnv, process.platform)) } + if (resolveRelaySpawnExecutable(shell) !== shell) { + stripInheritedCmderState(spawnEnv) + } const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, { terminalWindowsWslDistro }) @@ -3303,7 +3323,7 @@ export class PtyHandler { } let term: IPty try { - term = ptyMod.spawn(shell, shellLaunch.args, { + term = ptyMod.spawn(resolveRelaySpawnExecutable(shell), shellLaunch.args, { name: spawnEnv.TERM ?? 'xterm-256color', cols: entry.cols, rows: entry.rows, @@ -3353,6 +3373,7 @@ export class PtyHandler { // Why re-stored: a revived pane can be serialized again, and losing the // override on the second round trip is the same bug one restart later. ...(resolvedShellOverride ? { shellOverride } : {}), + ...(shellLaunch.env.CMDER_ROOT ? { cmderRoot: shellLaunch.env.CMDER_ROOT } : {}), ...(terminalWindowsWslDistro ? { wslDistro: terminalWindowsWslDistro } : {}), ownerBackend: resolvePtyOwnerBackend({ platform: process.platform, diff --git a/src/relay/pty-shell-launch.test.ts b/src/relay/pty-shell-launch.test.ts index 92ae2437f768..c12d1eeb1ec6 100644 --- a/src/relay/pty-shell-launch.test.ts +++ b/src/relay/pty-shell-launch.test.ts @@ -4,7 +4,11 @@ import { join } from 'node:path' import { spawnSync } from 'node:child_process' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { buildSshPtySpawnEnv } from '../main/providers/ssh-pty-spawn-env' -import { getRelayShellLaunchConfig, isRelayWslShell } from './pty-shell-launch' +import { + getRelayShellLaunchConfig, + isRelayWslShell, + resolveRelaySpawnExecutable +} from './pty-shell-launch' const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0 const itWithBash = hasBash ? it : it.skip @@ -388,3 +392,44 @@ describe('getRelayShellLaunchConfig', () => { expectBashOsc133Lifecycle(output) }) }) + +describe('relay Cmder launch', () => { + it('spawns cmd.exe for the cmder sentinel and leaves real shells alone', () => { + expect(resolveRelaySpawnExecutable('cmder')).toBe('cmd.exe') + expect(resolveRelaySpawnExecutable('pwsh.exe')).toBe('pwsh.exe') + }) + + it('never hands the cmder sentinel bare args to cmd.exe on Windows', () => { + const config = getRelayShellLaunchConfig('cmder', {}, 'win32') + // Why either shape: CI has no Cmder, a dev box may; both must be a valid cmd.exe launch. + if (config.args.length > 0) { + expect(config.args).toEqual([ + '/K', + 'call %ORCA_CMDER_INIT_QUOTE%%ORCA_CMDER_INIT%%ORCA_CMDER_INIT_QUOTE%' + ]) + expect(config.env.ORCA_CMDER_INIT).toMatch(/\\vendor\\init\.bat$/) + } else { + expect(config.env).toEqual({}) + } + }) + + it.skipIf(process.platform !== 'win32')( + 'resolves Cmder from the pane spawn env CMDER_ROOT', + () => { + const root = mkdtempSync(join(tmpdir(), 'relay-cmder-')) + try { + mkdirSync(join(root, 'vendor')) + writeFileSync(join(root, 'vendor', 'init.bat'), '@echo off') + const config = getRelayShellLaunchConfig('cmder', { CMDER_ROOT: root }, 'win32') + expect(config.args).toEqual([ + '/K', + 'call %ORCA_CMDER_INIT_QUOTE%%ORCA_CMDER_INIT%%ORCA_CMDER_INIT_QUOTE%' + ]) + expect(config.env.CMDER_ROOT).toBe(root) + expect(config.env.ORCA_CMDER_INIT).toBe(join(root, 'vendor', 'init.bat')) + } finally { + rmSync(root, { recursive: true, force: true }) + } + } + ) +}) diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index f617903056bf..065d2887e01a 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -7,6 +7,8 @@ import { type ShellStartupFeature } from '../main/shell-startup-features' import { inheritedZdotdirEnv, resolveInheritedZdotdir } from '../main/zsh-wrapper-dir-ownership' +import { applyCmderSpawnEnvironment, resolveCmderRoot } from '../main/cmder' +import { WINDOWS_CMDER_SHELL } from '../shared/windows-terminal-shell' import { ensureOverlayRestoreWrappers } from './pty-shell-overlay-wrappers' const RELAY_SHELL_READY_DIR = '.orca-relay/shell-ready' const POSIX_LOGIN_ARGS = ['-l'] @@ -54,6 +56,27 @@ function windowsShellArgs( return null } +/** Cmder is cmd.exe plus init.bat; without an install on this host it is plain cmd. */ +export function resolveRelaySpawnExecutable(shellPath: string): string { + return shellBasename(shellPath) === WINDOWS_CMDER_SHELL ? 'cmd.exe' : shellPath +} + +function getRelayCmderLaunchConfig(spawnEnv: Record): RelayShellLaunchConfig { + // Why merge: the pane's spawn env may carry a CMDER_ROOT the relay process itself lacks. + const root = resolveCmderRoot({ env: { ...process.env, ...spawnEnv } }) + if (!root) { + return { args: [], env: {}, supportsReadyMarker: false } + } + const env: Record = {} + applyCmderSpawnEnvironment(env, root) + // Why: node-pty backslash-escapes literal argv quotes, so the spaced path is quoted via env. + return { + args: ['/K', 'call %ORCA_CMDER_INIT_QUOTE%%ORCA_CMDER_INIT%%ORCA_CMDER_INIT_QUOTE%'], + env, + supportsReadyMarker: false + } +} + function getWrapperRoot(env: Record): string { return join(env.HOME || process.env.HOME || homedir(), RELAY_SHELL_READY_DIR) } @@ -75,6 +98,9 @@ export function getRelayShellLaunchConfig( supportsReadyMarker: false } if (platform === 'win32') { + if (shellName === WINDOWS_CMDER_SHELL) { + return getRelayCmderLaunchConfig(env) + } // Why: pwsh also exists on POSIX remotes; Windows-specific shell args must // only apply when the relay itself is running on native Windows. return { diff --git a/src/renderer/src/components/onboarding/WindowsTerminalStep.tsx b/src/renderer/src/components/onboarding/WindowsTerminalStep.tsx index 257e9d47b9c3..8473766fdfd4 100644 --- a/src/renderer/src/components/onboarding/WindowsTerminalStep.tsx +++ b/src/renderer/src/components/onboarding/WindowsTerminalStep.tsx @@ -1,7 +1,10 @@ import { Check } from 'lucide-react' import { useCallback, useState } from 'react' import type { BuiltInWindowsTerminalShell } from '../../../../shared/windows-terminal-shell' -import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell' +import { + WINDOWS_CMDER_SHELL, + WINDOWS_GIT_BASH_SHELL +} from '../../../../shared/windows-terminal-shell' import type { GlobalSettings } from '../../../../shared/global-settings-types' import { cn } from '@/lib/utils' import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' @@ -41,7 +44,8 @@ function normalizeWindowsShell(value: string | null | undefined): BuiltInWindows value === 'powershell.exe' || value === 'cmd.exe' || value === 'wsl.exe' || - value === WINDOWS_GIT_BASH_SHELL + value === WINDOWS_GIT_BASH_SHELL || + value === WINDOWS_CMDER_SHELL ) { return value } @@ -62,6 +66,8 @@ export function WindowsTerminalStep({ ? [selectedWslDistroName, ...capabilities.wslDistros] : capabilities.wslDistros const showGitBashOption = capabilities.gitBashAvailable || windowsShell === WINDOWS_GIT_BASH_SHELL + const cmderAvailable = capabilities.cmderAvailable === true + const showCmderOption = cmderAvailable || windowsShell === WINDOWS_CMDER_SHELL const showWslOption = capabilities.wslAvailable || windowsShell === 'wsl.exe' const setSelectPortalHost = useCallback((node: HTMLDivElement | null) => { @@ -113,6 +119,24 @@ export function WindowsTerminalStep({ } satisfies ShellOption ] : []), + ...(showCmderOption + ? [ + { + value: WINDOWS_CMDER_SHELL, + label: translate('auto.components.onboarding.WindowsTerminalStep.cmder', 'Cmder'), + description: cmderAvailable + ? translate( + 'auto.components.onboarding.WindowsTerminalStep.cmderDescription', + 'Opens Command Prompt with your Cmder prompt, aliases and Clink.' + ) + : translate( + 'auto.components.onboarding.WindowsTerminalStep.cmderUnavailable', + 'Selected, but Cmder was not detected on this machine.' + ), + disabled: !cmderAvailable + } satisfies ShellOption + ] + : []), ...(showWslOption ? [ { diff --git a/src/renderer/src/components/onboarding/windows-terminal-onboarding-telemetry.ts b/src/renderer/src/components/onboarding/windows-terminal-onboarding-telemetry.ts index 922f7b866a0b..d8cef15db039 100644 --- a/src/renderer/src/components/onboarding/windows-terminal-onboarding-telemetry.ts +++ b/src/renderer/src/components/onboarding/windows-terminal-onboarding-telemetry.ts @@ -21,7 +21,8 @@ export function bucketWindowsTerminalShell( if (normalized === 'powershell.exe' || normalized === 'pwsh.exe') { return 'powershell' } - if (normalized === 'cmd.exe') { + // Why: Cmder is cmd.exe under the hood; the telemetry enum has no separate bucket. + if (normalized === 'cmd.exe' || normalized === 'cmder') { return 'command_prompt' } if (normalized === WINDOWS_GIT_BASH_SHELL || normalizedName === 'bash.exe') { diff --git a/src/renderer/src/components/settings/TerminalPane.tsx b/src/renderer/src/components/settings/TerminalPane.tsx index 622fd3c16257..c56a0b7e3fdb 100644 --- a/src/renderer/src/components/settings/TerminalPane.tsx +++ b/src/renderer/src/components/settings/TerminalPane.tsx @@ -48,6 +48,7 @@ type TerminalPaneProps = { pwshAvailable?: boolean /** Whether Git for Windows bash.exe is installed on this machine. */ gitBashAvailable?: boolean + cmderAvailable?: boolean /** Whether the active terminal host is Windows, even if the client is not. */ isWindowsTerminalHost?: boolean } @@ -59,6 +60,7 @@ export function TerminalPane({ setScrollbackMode, pwshAvailable, gitBashAvailable = false, + cmderAvailable = false, isWindowsTerminalHost }: TerminalPaneProps): React.JSX.Element { const searchQuery = useAppStore((state) => state.settingsSearchQuery) @@ -147,7 +149,10 @@ export function TerminalPane({ } updateSettings( value === 'system' - ? { terminalDefaultShell: '', terminalDefaultShellArgs: undefined } + ? { + terminalDefaultShell: '', + terminalDefaultShellArgs: undefined + } : { terminalDefaultShell: configuredShell || systemShell } ) }} @@ -176,7 +181,9 @@ export function TerminalPane({ )} onChange={(event) => { setShellValidationError(null) - updateSettings({ terminalDefaultShell: event.target.value.trimStart() }) + updateSettings({ + terminalDefaultShell: event.target.value.trimStart() + }) }} onBlur={() => void validateShell()} className="w-full" @@ -275,7 +282,9 @@ export function TerminalPane({ .split('\n') .filter((argument) => argument.length > 0) setCustomShellArgs(nextArgs) - updateSettings({ terminalDefaultShellArgs: nextArgs }) + updateSettings({ + terminalDefaultShellArgs: nextArgs + }) }} placeholder={translate( 'auto.components.settings.TerminalPane.f58cb71f53', @@ -307,6 +316,7 @@ export function TerminalPane({ updateSettings={updateSettings} windowsShell={windowsShell} gitBashAvailable={gitBashAvailable} + cmderAvailable={cmderAvailable} /> ) : null, matchesSettingsSearch(searchQuery, getTerminalRenderingSearchEntries()) ? ( diff --git a/src/renderer/src/components/settings/TerminalWindowsShellSection.tsx b/src/renderer/src/components/settings/TerminalWindowsShellSection.tsx index 6009e551653c..80220cdbd4e6 100644 --- a/src/renderer/src/components/settings/TerminalWindowsShellSection.tsx +++ b/src/renderer/src/components/settings/TerminalWindowsShellSection.tsx @@ -1,5 +1,8 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' -import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell' +import { + WINDOWS_CMDER_SHELL, + WINDOWS_GIT_BASH_SHELL +} from '../../../../shared/windows-terminal-shell' import { SettingsRow, SettingsSegmentedControl, @@ -13,6 +16,7 @@ type TerminalWindowsShellSectionProps = { updateSettings: (updates: Partial) => void windowsShell: string gitBashAvailable: boolean + cmderAvailable: boolean } function windowsShellLabel(shell: string, label: string): React.JSX.Element { @@ -27,9 +31,11 @@ function windowsShellLabel(shell: string, label: string): React.JSX.Element { export function TerminalWindowsShellSection({ updateSettings, windowsShell, - gitBashAvailable + gitBashAvailable, + cmderAvailable }: TerminalWindowsShellSectionProps): React.JSX.Element { const showGitBashOption = gitBashAvailable || windowsShell === WINDOWS_GIT_BASH_SHELL + const showCmderOption = cmderAvailable || windowsShell === WINDOWS_CMDER_SHELL // Why: selecting WSL here would omit its required distro, but an existing WSL default must stay visible. const showWslOption = windowsShell === 'wsl.exe' @@ -59,6 +65,7 @@ export function TerminalWindowsShellSection({ 'command prompt', 'git bash', 'bash.exe', + 'cmder', 'default' ]} > @@ -121,6 +128,22 @@ export function TerminalWindowsShellSection({ } ] : []), + ...(showCmderOption + ? [ + { + value: WINDOWS_CMDER_SHELL, + label: windowsShellLabel( + WINDOWS_CMDER_SHELL, + translate('auto.components.settings.TerminalPane.cmder', 'Cmder') + ), + ariaLabel: translate( + 'auto.components.settings.TerminalPane.cmder', + 'Cmder' + ), + disabled: !cmderAvailable + } + ] + : []), ...(showWslOption ? [ { diff --git a/src/renderer/src/components/settings/settings-interface-primary-section-renderers.tsx b/src/renderer/src/components/settings/settings-interface-primary-section-renderers.tsx index 8526db467dfb..42e7e69e4b31 100644 --- a/src/renderer/src/components/settings/settings-interface-primary-section-renderers.tsx +++ b/src/renderer/src/components/settings/settings-interface-primary-section-renderers.tsx @@ -30,6 +30,7 @@ export function renderTerminalSettingsSection(context: SettingsRenderContext): R wslCapabilitiesLoading={terminal.windowsTerminalCapabilities.isLoading} pwshAvailable={terminal.windowsTerminalCapabilities.pwshAvailable} gitBashAvailable={terminal.windowsTerminalCapabilities.gitBashAvailable} + cmderAvailable={terminal.windowsTerminalCapabilities.cmderAvailable ?? false} isWindowsTerminalHost={terminal.isWindowsTerminalHost} /> ) : null} diff --git a/src/renderer/src/components/tab-bar/shell-icons.tsx b/src/renderer/src/components/tab-bar/shell-icons.tsx index a5a896154a9e..c2567b92c5ef 100644 --- a/src/renderer/src/components/tab-bar/shell-icons.tsx +++ b/src/renderer/src/components/tab-bar/shell-icons.tsx @@ -1,5 +1,8 @@ import React from 'react' -import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell' +import { + WINDOWS_CMDER_SHELL, + WINDOWS_GIT_BASH_SHELL +} from '../../../../shared/windows-terminal-shell' import { translate } from '@/i18n/i18n' import gitForWindowsLogoUrl from '../../../../../resources/gwindows_logo.svg?url' @@ -136,7 +139,8 @@ export function ShellIcon({ if (normalized === 'powershell.exe' || normalized === 'pwsh.exe') { return } - if (normalized === 'cmd.exe') { + // Why: Cmder is cmd.exe plus init.bat; the label, not the glyph, tells them apart. + if (normalized === 'cmd.exe' || normalized === WINDOWS_CMDER_SHELL) { return } if (normalized === 'wsl.exe' || normalized.startsWith('wsl')) { diff --git a/src/renderer/src/components/tab-bar/tab-bar-windows-shell-options.ts b/src/renderer/src/components/tab-bar/tab-bar-windows-shell-options.ts index b691359d1b4d..a1e749a51c28 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-windows-shell-options.ts +++ b/src/renderer/src/components/tab-bar/tab-bar-windows-shell-options.ts @@ -1,5 +1,8 @@ import type { BuiltInWindowsTerminalShell } from '../../../../shared/windows-terminal-shell' -import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell' +import { + WINDOWS_CMDER_SHELL, + WINDOWS_GIT_BASH_SHELL +} from '../../../../shared/windows-terminal-shell' import { translate } from '@/i18n/i18n' import type { getProjectRuntimeShellMenuMode } from './use-tab-bar-runtime-model' @@ -14,6 +17,7 @@ export function buildWindowsShellMenuEntries({ projectRuntimeShellMenuMode, defaultWindowsShell, gitBashAvailable, + cmderAvailable = false, wslAvailable }: { showWindowsShellMenu: boolean @@ -21,6 +25,7 @@ export function buildWindowsShellMenuEntries({ projectRuntimeShellMenuMode: ReturnType defaultWindowsShell: string gitBashAvailable: boolean + cmderAvailable?: boolean wslAvailable: boolean }): WindowsShellMenuEntry[] | undefined { if (!showWindowsShellMenu || !hasShellLauncher) { @@ -46,6 +51,12 @@ export function buildWindowsShellMenuEntries({ shell: WINDOWS_GIT_BASH_SHELL }) } + if (cmderAvailable) { + allShells.push({ + label: translate('auto.components.tab.bar.TabBar.cmder', 'Cmder'), + shell: WINDOWS_CMDER_SHELL + }) + } } if (includeWslShell && wslAvailable) { allShells.push({ diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts index dcbc45df788e..e0f0d504f567 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts @@ -150,6 +150,7 @@ export function useTabBarCreateMenuController({ projectRuntimeShellMenuMode, defaultWindowsShell, gitBashAvailable: windowsTerminalCapabilities.gitBashAvailable, + cmderAvailable: windowsTerminalCapabilities.cmderAvailable, wslAvailable: windowsTerminalCapabilities.wslAvailable }) }, [ @@ -158,6 +159,7 @@ export function useTabBarCreateMenuController({ projectRuntimeShellMenuMode, showWindowsShellMenu, windowsTerminalCapabilities.gitBashAvailable, + windowsTerminalCapabilities.cmderAvailable, windowsTerminalCapabilities.wslAvailable ]) const createMenuOptions = useMemo( diff --git a/src/renderer/src/lib/windows-terminal-capabilities.test.ts b/src/renderer/src/lib/windows-terminal-capabilities.test.ts index 7e7568f2e09a..29d3b8468e42 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.test.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.test.ts @@ -141,6 +141,7 @@ describe('windows terminal capabilities', () => { wslDistros: ['Ubuntu'], pwshAvailable: true, gitBashAvailable: true, + cmderAvailable: false, hostPlatform: 'win32', isLoading: false } @@ -173,6 +174,7 @@ describe('windows terminal capabilities', () => { wslDistros: [], pwshAvailable: false, gitBashAvailable: false, + cmderAvailable: false, hostPlatform: 'win32', isLoading: false }) @@ -850,8 +852,29 @@ describe('windows terminal capabilities', () => { wslDistros: [], pwshAvailable: false, gitBashAvailable: false, + cmderAvailable: false, hostPlatform: 'win32', isLoading: false }) }) + + it('reports Cmder availability from the local bridge', async () => { + vi.stubGlobal('window', { + api: { + wsl: { + isAvailable: vi.fn().mockResolvedValue(false), + listDistros: vi.fn().mockResolvedValue([]) + }, + pwsh: { isAvailable: vi.fn().mockResolvedValue(false) }, + gitBash: { isAvailable: vi.fn().mockResolvedValue(false) }, + cmder: { isAvailable: vi.fn().mockResolvedValue(true) }, + runtime: { getStatus: vi.fn().mockResolvedValue({ hostPlatform: 'win32' }) } + } + }) + + await expect(loadWindowsTerminalCapabilities()).resolves.toMatchObject({ + cmderAvailable: true, + hostPlatform: 'win32' + }) + }) }) diff --git a/src/renderer/src/lib/windows-terminal-capabilities.ts b/src/renderer/src/lib/windows-terminal-capabilities.ts index 8a2f468b134d..f8c6b18cfd3d 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.ts @@ -10,6 +10,8 @@ export type WindowsTerminalCapabilities = { wslDistros: string[] pwshAvailable: boolean gitBashAvailable: boolean + /** Absent from hosts/relays that predate Cmder support. */ + cmderAvailable?: boolean hostPlatform: NodeJS.Platform | null /** Host-owned PID-reuse proof; absent means the host did not advertise it. */ windowsProcessStartTimeAvailable?: boolean @@ -218,7 +220,10 @@ export function useWindowsTerminalCapabilities( useEffect(() => { if (!enabled) { - setState({ ownerKey: resolvedOwnerKey, capabilities: UNAVAILABLE_CAPABILITIES }) + setState({ + ownerKey: resolvedOwnerKey, + capabilities: UNAVAILABLE_CAPABILITIES + }) return } let cancelled = false diff --git a/src/renderer/src/lib/windows-terminal-capability-read.ts b/src/renderer/src/lib/windows-terminal-capability-read.ts index 9a77538cefc5..ca0ffe537614 100644 --- a/src/renderer/src/lib/windows-terminal-capability-read.ts +++ b/src/renderer/src/lib/windows-terminal-capability-read.ts @@ -49,14 +49,22 @@ export async function readWindowsTerminalCapabilities( } if (target.kind === 'local') { - const [wslAvailable, wslDistros, pwshAvailable, gitBashAvailable, runtimeStatus] = - 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().catch(() => null) - ]) + const [ + wslAvailable, + wslDistros, + pwshAvailable, + gitBashAvailable, + cmderAvailable, + runtimeStatus + ] = 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), + // Why optional: embedders and test harnesses may expose a preload without the cmder bridge. + (window.api.cmder?.isAvailable() ?? Promise.resolve(false)).catch(() => false), + window.api.runtime.getStatus().catch(() => null) + ]) const reconciledWslAvailable = await reconcileWslAvailability(wslAvailable, wslDistros, () => window.api.wsl.isAvailable() ) @@ -65,15 +73,18 @@ export async function readWindowsTerminalCapabilities( wslDistros, pwshAvailable, gitBashAvailable, + cmderAvailable, hostPlatform: runtimeStatus?.hostPlatform ?? null, ...(runtimeStatus?.windowsProcessStartTimeAvailable !== undefined - ? { windowsProcessStartTimeAvailable: runtimeStatus.windowsProcessStartTimeAvailable } + ? { + windowsProcessStartTimeAvailable: runtimeStatus.windowsProcessStartTimeAvailable + } : {}), isLoading: false } } - const [wslAvailable, wslDistros, pwshAvailable, gitBashAvailable, hostPlatform] = + const [wslAvailable, wslDistros, pwshAvailable, gitBashAvailable, cmderAvailable, hostPlatform] = await Promise.all([ callRuntimeRpc(target, 'host.wsl.isAvailable', undefined, { timeoutMs: 15_000 @@ -87,7 +98,12 @@ export async function readWindowsTerminalCapabilities( callRuntimeRpc(target, 'host.gitBash.isAvailable', undefined, { timeoutMs: 15_000 }).catch(() => false), - callRuntimeRpc(target, 'status.get', undefined, { timeoutMs: 15_000 }) + callRuntimeRpc(target, 'host.cmder.isAvailable', undefined, { + timeoutMs: 15_000 + }).catch(() => false), + callRuntimeRpc(target, 'status.get', undefined, { + timeoutMs: 15_000 + }) .then((status) => status.hostPlatform ?? null) .catch(() => null) ]) @@ -101,6 +117,7 @@ export async function readWindowsTerminalCapabilities( wslDistros, pwshAvailable, gitBashAvailable, + cmderAvailable, hostPlatform, isLoading: false } diff --git a/src/renderer/src/lib/windows-terminal-capability-reprobe.ts b/src/renderer/src/lib/windows-terminal-capability-reprobe.ts index f9b9d44b0256..6b77f648da8a 100644 --- a/src/renderer/src/lib/windows-terminal-capability-reprobe.ts +++ b/src/renderer/src/lib/windows-terminal-capability-reprobe.ts @@ -31,6 +31,7 @@ function capabilitySignature(capabilities: WindowsTerminalCapabilities): string capabilities.wslDistros.join('\u0000'), capabilities.pwshAvailable, capabilities.gitBashAvailable, + capabilities.cmderAvailable, capabilities.hostPlatform ?? '', capabilities.windowsProcessStartTimeAvailable ].join('|') diff --git a/src/renderer/src/store/terminals/terminal-workspace-routing.ts b/src/renderer/src/store/terminals/terminal-workspace-routing.ts index ae07c3e7b8e0..341678417fdb 100644 --- a/src/renderer/src/store/terminals/terminal-workspace-routing.ts +++ b/src/renderer/src/store/terminals/terminal-workspace-routing.ts @@ -4,7 +4,10 @@ import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import { isWslUncPath } from '../../../../shared/wsl-paths' import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../../shared/local-windows-terminal-runtime' -import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell' +import { + WINDOWS_CMDER_SHELL, + WINDOWS_GIT_BASH_SHELL +} from '../../../../shared/windows-terminal-shell' import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { getIndexedRepoMap, getIndexedWorktreeMap } from '../worktree-repo-index' @@ -19,7 +22,8 @@ export function isAllowedRemoteWindowsTerminalShell(shell: string | undefined): shell === 'pwsh.exe' || shell === 'cmd.exe' || shell === 'wsl.exe' || - shell === WINDOWS_GIT_BASH_SHELL + shell === WINDOWS_GIT_BASH_SHELL || + shell === WINDOWS_CMDER_SHELL ) } diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 635e6dd924e5..33b7d5aff7be 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -119,7 +119,11 @@ function createWebPreloadApi(): Partial { macosTccPrompts: createMacosTccPromptsApi(), codexConfigSync: { status: () => - Promise.resolve({ state: 'synced', reason: null, systemConfigPath: '' } as const) + Promise.resolve({ + state: 'synced', + reason: null, + systemConfigPath: '' + } as const) }, developerPermissions: createDeveloperPermissionsApi(), computerUsePermissions: createComputerUsePermissionsApi(), @@ -138,6 +142,10 @@ function createWebPreloadApi(): Partial { gitBash: { isAvailable: () => callRuntimeResult('host.gitBash.isAvailable').catch(() => false) }, + cmder: { + // Why: an older host lacks this method; the catch reads that as "no Cmder". + isAvailable: () => callRuntimeResult('host.cmder.isAvailable').catch(() => false) + }, ...createWebAgentStatusApi(), ...createWebMobileApi(), ...createWebTelemetryApi() diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index 3e8bb8abbca3..730633893a11 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -94,6 +94,7 @@ export function buildDefaultSettings(args: { terminalWindowsShell: 'powershell.exe', terminalDefaultShell: '', terminalWindowsWslDistro: null, + terminalWindowsCmderPath: '', localAccountRuntime: 'auto', localAccountRuntimeDefaultedToAutoForAllUsers: true, localAccountWslDistro: null, diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index d7a51e53fbe4..69e549ab0705 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -168,6 +168,8 @@ export type GlobalSettings = NativeChatGlobalSettings & { terminalDefaultShell?: string /** Optional argv passed to the configured Unix shell for ordinary interactive panes. */ terminalDefaultShellArgs?: string[] + /** Windows-only Cmder folder override (holds vendor\init.bat); not in the UI, empty auto-detects via CMDER_ROOT. */ + terminalWindowsCmderPath?: string /** Pins the WSL distro for terminals/agent scans instead of WSL's current global default. */ terminalWindowsWslDistro?: string | null /** Account/auth location; auto follows the global Windows runtime while host/wsl pin it. */ diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 7276915e1d93..f3962ab7644c 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -915,6 +915,7 @@ export const RPC_PARAMS_BY_METHOD = { 'gitlab.updateMRState': UpdateMrState, 'gitlab.workItemByPath': WorkItemByPath, 'gitlab.workItemDetails': WorkItemDetails, + 'host.cmder.isAvailable': null, 'host.gitBash.isAvailable': null, 'host.platform': null, 'host.pwsh.isAvailable': null, diff --git a/src/shared/windows-terminal-shell.test.ts b/src/shared/windows-terminal-shell.test.ts index e9c9653bd997..d714305deadd 100644 --- a/src/shared/windows-terminal-shell.test.ts +++ b/src/shared/windows-terminal-shell.test.ts @@ -47,6 +47,7 @@ describe('isSupportedWindowsShellOverride', () => { 'bash.exe', 'cmd', 'cmd.exe', + 'cmder', 'git-bash', 'powershell', 'powershell.exe', diff --git a/src/shared/windows-terminal-shell.ts b/src/shared/windows-terminal-shell.ts index 625eefbb4147..6dce98671aab 100644 --- a/src/shared/windows-terminal-shell.ts +++ b/src/shared/windows-terminal-shell.ts @@ -1,12 +1,15 @@ import type { AgentStartupShell } from './tui-agent-startup-shell' export const WINDOWS_GIT_BASH_SHELL = 'git-bash' +/** Sentinel for cmd.exe bootstrapped through Cmder's `vendor\init.bat` (clink, aliases, git prompt). */ +export const WINDOWS_CMDER_SHELL = 'cmder' export type BuiltInWindowsTerminalShell = | 'powershell.exe' | 'cmd.exe' | 'wsl.exe' | typeof WINDOWS_GIT_BASH_SHELL + | typeof WINDOWS_CMDER_SHELL /** * Classifies a configured `terminalWindowsShell` value into the startup-shell @@ -24,6 +27,9 @@ export function resolveWindowsShellStartupFamily( if (trimmed === WINDOWS_GIT_BASH_SHELL) { return 'posix' } + if (trimmed.toLowerCase() === WINDOWS_CMDER_SHELL) { + return 'cmd' + } const basename = trimmed.replaceAll('\\', '/').split('/').pop()?.toLowerCase() ?? '' if (basename === 'cmd.exe') { return 'cmd' @@ -82,7 +88,8 @@ const WINDOWS_SHELL_OVERRIDE_CANONICAL_NAMES: ReadonlyMap = new // the one host that hard-failed a setting the local and daemon PTYs accept. ['bash.exe', 'bash.exe'], ['bash', 'bash.exe'], - [WINDOWS_GIT_BASH_SHELL, WINDOWS_GIT_BASH_SHELL] + [WINDOWS_GIT_BASH_SHELL, WINDOWS_GIT_BASH_SHELL], + [WINDOWS_CMDER_SHELL, WINDOWS_CMDER_SHELL] ]) /** Canonical spelling for an accepted override (case-insensitive), or undefined when refused. */