From 469924ab19c13b80435cdb214b35bd60088e0889 Mon Sep 17 00:00:00 2001 From: B1nh M1nh <43268322+b1nhm1nh@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:52:30 +0700 Subject: [PATCH 1/7] feat(terminal): add Cmder as a Windows terminal shell Adds a `cmder` shell choice (settings, new-tab menu, `terminal create --shell cmder`) that spawns cmd.exe and calls Cmder's vendor\init.bat before any startup command, so panes get Clink, Cmder aliases, and the git prompt while staying in the requested cwd. Cmder is found via CMDER_ROOT or common install dirs; hosts without it degrade to plain cmd. The init path is quoted through env vars because node-pty backslash-escapes literal argv quotes that cmd.exe cannot read. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/main/cmder.smoke.node-pty.test.ts | 47 ++++++++ src/main/cmder.test.ts | 54 ++++++++++ src/main/cmder.ts | 102 ++++++++++++++++++ .../pty-subprocess/shell-launch-plan.ts | 13 ++- src/main/ipc/app.ts | 7 +- src/main/providers/local-pty-launch-plan.ts | 20 +++- ...-pty-provider-windows-shell-launch.test.ts | 47 ++++++++ .../local-pty-windows-spawn-environment.ts | 7 +- src/main/providers/windows-shell-args.test.ts | 17 +++ src/main/providers/windows-shell-args.ts | 11 +- .../runtime/rpc/methods/host-capabilities.ts | 6 ++ .../runtime-rpc-mobile-method-allowlist.ts | 1 + src/preload/api-types.ts | 1 + src/preload/api/git-bash-bridge.ts | 4 + src/preload/api/runtime-api.ts | 10 +- src/preload/index.ts | 3 +- .../windows-terminal-onboarding-telemetry.ts | 3 +- .../src/components/settings/TerminalPane.tsx | 16 ++- .../settings/TerminalWindowsShellSection.tsx | 27 ++++- ...gs-interface-primary-section-renderers.tsx | 1 + .../src/components/tab-bar/shell-icons.tsx | 8 +- .../tab-bar/tab-bar-windows-shell-options.ts | 13 ++- .../use-tab-bar-create-menu-controller.ts | 2 + .../lib/windows-terminal-capabilities.test.ts | 23 ++++ .../src/lib/windows-terminal-capabilities.ts | 7 +- .../lib/windows-terminal-capability-read.ts | 39 +++++-- .../windows-terminal-capability-reprobe.ts | 1 + .../terminals/terminal-workspace-routing.ts | 8 +- src/renderer/src/web/web-preload-api.ts | 10 +- .../rpc-params-catalog.generated.ts | 1 + src/shared/windows-terminal-shell.test.ts | 1 + src/shared/windows-terminal-shell.ts | 9 +- 32 files changed, 482 insertions(+), 37 deletions(-) create mode 100644 src/main/cmder.smoke.node-pty.test.ts create mode 100644 src/main/cmder.test.ts create mode 100644 src/main/cmder.ts 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..9f5936b474c1 --- /dev/null +++ b/src/main/cmder.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { + applyCmderSpawnEnvironment, + 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', () => { + const env: Record = {} + 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: '"' + }) + }) +}) diff --git a/src/main/cmder.ts b/src/main/cmder.ts new file mode 100644 index 000000000000..1f07f6c3130a --- /dev/null +++ b/src/main/cmder.ts @@ -0,0 +1,102 @@ +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' + +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: 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 +} + +/** Env Cmder's init.bat expects, plus the path/quote pair the cmd `/K` chain expands. */ +export function applyCmderSpawnEnvironment(env: Record, cmderRoot: string): void { + 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..cbecd851a372 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) 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..2a74baa31f5d 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 { @@ -151,7 +152,10 @@ function readCommandStdout( } try { - child = spawn(command, args, { detached: true, stdio: ['ignore', 'pipe', 'ignore'] }) + child = spawn(command, args, { + detached: true, + stdio: ['ignore', 'pipe', 'ignore'] + }) let stdout = '' child.stdout?.setEncoding('utf8') child.stdout?.on('data', (chunk: string) => { @@ -277,6 +281,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/providers/local-pty-launch-plan.ts b/src/main/providers/local-pty-launch-plan.ts index 455cee3c4653..0e3e6330085d 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,7 @@ function createWindowsLocalPtyLaunchPlan( } const normalizedShellFamily = pathWin32.basename(shellFamily).toLowerCase() const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellFamily) + const cmderRoot = resolveWindowsCmderShellRoot(shellFamily) // 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 +147,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 +183,9 @@ function createWindowsLocalPtyLaunchPlan( cwd, defaultCwd, seed.launchWslContext, - args.command + args.command, + undefined, + cmderRoot !== null ) return finalizeLocalPtyLaunchPlan(seed, { shellPath, @@ -182,7 +193,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-provider-windows-shell-launch.test.ts b/src/main/providers/local-pty-provider-windows-shell-launch.test.ts index bc96cdb1d6af..f8f095ecd4fc 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,53 @@ 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('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/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/runtime-api.ts b/src/preload/api/runtime-api.ts index f158699ee47b..b0f4db274202 100644 --- a/src/preload/api/runtime-api.ts +++ b/src/preload/api/runtime-api.ts @@ -33,7 +33,12 @@ export type RuntimeApi = { callback: (response: RuntimeRpcResponse) => void ) => Promise getTerminalFitOverrides: () => Promise< - { ptyId: string; mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number }[] + { + ptyId: string + mode: 'mobile-fit' | 'remote-desktop-fit' + cols: number + rows: number + }[] > getTerminalDrivers: () => Promise< { @@ -154,4 +159,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/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/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. */ From 920a7b202bcd46d47d31d0e02795c730935a1507 Mon Sep 17 00:00:00 2001 From: B1nh M1nh <43268322+b1nhm1nh@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:39:25 +0700 Subject: [PATCH 2/7] fix(terminal): harden Cmder launch and add a Cmder folder setting - Strip inherited CMDER_CONFIGURED/CMDER_INIT_* so Orca launched from a Cmder console still runs init.bat in new panes. - SSH relay on Windows spawns cmd.exe + init.bat for the `cmder` override instead of trying to exec a file named `cmder` (fresh spawn and revive). - Onboarding keeps a saved `cmder` default instead of resetting to PowerShell. - New Settings > Windows Shell > Cmder Folder (terminalWindowsCmderPath); empty auto-detects. Carried to the daemon as ORCA_CMDER_ROOT and stripped before the shell sees it. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/main/cmder.test.ts | 30 +++++++++++++- src/main/cmder.ts | 30 ++++++++++++++ .../pty-subprocess/shell-launch-plan.ts | 2 +- src/main/ipc/pty/ipc/spawn-options.ts | 5 +++ src/main/ipc/pty/runtime/spawn-options.ts | 7 ++++ .../startup/main-process-ready-foundation.ts | 5 +++ src/relay/pty-handler.ts | 17 ++++++-- src/relay/pty-shell-launch.test.ts | 27 ++++++++++++- src/relay/pty-shell-launch.ts | 25 ++++++++++++ .../onboarding/WindowsTerminalStep.tsx | 8 +++- .../src/components/settings/TerminalPane.tsx | 1 + .../settings/TerminalWindowsShellSection.tsx | 39 ++++++++++++++++++- src/shared/default-global-settings.ts | 1 + src/shared/global-settings-types.ts | 2 + 14 files changed, 189 insertions(+), 10 deletions(-) diff --git a/src/main/cmder.test.ts b/src/main/cmder.test.ts index 9f5936b474c1..c86ae822aace 100644 --- a/src/main/cmder.test.ts +++ b/src/main/cmder.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { applyCmderSpawnEnvironment, + applyConfiguredCmderRootEnv, + setConfiguredCmderRoot, getCmderRootCandidates, resolveCmderRoot, resolveWindowsCmderShellRoot @@ -42,8 +44,12 @@ describe('Cmder discovery', () => { expect(resolveWindowsCmderShellRoot('cmd.exe', options)).toBeNull() }) - it('sets CMDER_ROOT and the init path/quote pair', () => { - const env: Record = {} + 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', @@ -51,4 +57,24 @@ describe('Cmder discovery', () => { 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' }) + }) }) diff --git a/src/main/cmder.ts b/src/main/cmder.ts index 1f07f6c3130a..1d344198c524 100644 --- a/src/main/cmder.ts +++ b/src/main/cmder.ts @@ -4,6 +4,25 @@ 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() + if (root) { + spawnOptions.env = { ...spawnOptions.env, [ORCA_CMDER_ROOT_ENV]: root } + } +} + +/** 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 @@ -44,6 +63,8 @@ export function getCmderRootCandidates(env: NodeJS.ProcessEnv = process.env): st } } + // 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']) @@ -86,8 +107,17 @@ 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] = '"' diff --git a/src/main/daemon/pty-subprocess/shell-launch-plan.ts b/src/main/daemon/pty-subprocess/shell-launch-plan.ts index cbecd851a372..82e2e41e716c 100644 --- a/src/main/daemon/pty-subprocess/shell-launch-plan.ts +++ b/src/main/daemon/pty-subprocess/shell-launch-plan.ts @@ -71,7 +71,7 @@ export function createPtyShellLaunchPlan( if (process.platform === 'win32') { const normalizedShellFamily = pathWin32.basename(shellPath).toLowerCase() const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellPath) - const cmderRoot = resolveWindowsCmderShellRoot(shellPath) + const cmderRoot = resolveWindowsCmderShellRoot(shellPath, { env }) const resolvedShellFamily: WindowsPowerShellShellFamily = normalizedShellFamily === 'powershell.exe' || normalizedShellFamily === 'pwsh.exe' ? normalizedShellFamily 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/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/relay/pty-handler.ts b/src/relay/pty-handler.ts index d107d249d1aa..7ced3f74b912 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' @@ -2125,6 +2130,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 +2158,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, @@ -3293,6 +3301,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 +3314,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, diff --git a/src/relay/pty-shell-launch.test.ts b/src/relay/pty-shell-launch.test.ts index 92ae2437f768..9f657105632a 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,24 @@ 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({}) + } + }) +}) diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index f617903056bf..d56cd1920bf7 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,26 @@ 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(): RelayShellLaunchConfig { + const root = resolveCmderRoot() + 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 +97,9 @@ export function getRelayShellLaunchConfig( supportsReadyMarker: false } if (platform === 'win32') { + if (shellName === WINDOWS_CMDER_SHELL) { + return getRelayCmderLaunchConfig() + } // 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..ec7b4c4adbb2 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 } diff --git a/src/renderer/src/components/settings/TerminalPane.tsx b/src/renderer/src/components/settings/TerminalPane.tsx index c56a0b7e3fdb..eb1c7cd17ef3 100644 --- a/src/renderer/src/components/settings/TerminalPane.tsx +++ b/src/renderer/src/components/settings/TerminalPane.tsx @@ -317,6 +317,7 @@ export function TerminalPane({ windowsShell={windowsShell} gitBashAvailable={gitBashAvailable} cmderAvailable={cmderAvailable} + cmderPath={settings.terminalWindowsCmderPath ?? ''} /> ) : null, matchesSettingsSearch(searchQuery, getTerminalRenderingSearchEntries()) ? ( diff --git a/src/renderer/src/components/settings/TerminalWindowsShellSection.tsx b/src/renderer/src/components/settings/TerminalWindowsShellSection.tsx index 80220cdbd4e6..0aef0e934765 100644 --- a/src/renderer/src/components/settings/TerminalWindowsShellSection.tsx +++ b/src/renderer/src/components/settings/TerminalWindowsShellSection.tsx @@ -9,6 +9,8 @@ import { SettingsSubsectionHeader } from './SettingsFormControls' import { SearchableSetting } from './SearchableSetting' +import { Input } from '../ui/input' +import { refreshWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { translate } from '@/i18n/i18n' import { ShellIcon } from '../tab-bar/shell-icons' @@ -17,6 +19,7 @@ type TerminalWindowsShellSectionProps = { windowsShell: string gitBashAvailable: boolean cmderAvailable: boolean + cmderPath: string } function windowsShellLabel(shell: string, label: string): React.JSX.Element { @@ -32,7 +35,8 @@ export function TerminalWindowsShellSection({ updateSettings, windowsShell, gitBashAvailable, - cmderAvailable + cmderAvailable, + cmderPath }: TerminalWindowsShellSectionProps): React.JSX.Element { const showGitBashOption = gitBashAvailable || windowsShell === WINDOWS_GIT_BASH_SHELL const showCmderOption = cmderAvailable || windowsShell === WINDOWS_CMDER_SHELL @@ -165,6 +169,39 @@ export function TerminalWindowsShellSection({ } /> + + + updateSettings({ terminalWindowsCmderPath: event.target.value.trimStart() }) + } + // Why: availability gates the Cmder options, so re-probe once the path is committed. + onBlur={() => void refreshWindowsTerminalCapabilities()} + className="w-64" + aria-label={translate( + 'auto.components.settings.TerminalPane.cmderPath', + 'Cmder Folder' + )} + spellCheck={false} + /> + } + /> + ) 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..62e3fd1ea5fd 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 install folder (holds vendor\init.bat); empty means auto-detect. */ + 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. */ From fb07e2736b38ce5110ab7afdad99eb1297fbae5b Mon Sep 17 00:00:00 2001 From: B1nh M1nh <43268322+b1nhm1nh@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:03:22 +0700 Subject: [PATCH 3/7] refactor(settings): hide the Cmder folder input Auto-detection (CMDER_ROOT, then standard install dirs) covers the common setups, so the extra Settings row was noise. terminalWindowsCmderPath stays as a hidden override and is still honored by every spawn path. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/components/settings/TerminalPane.tsx | 1 - .../settings/TerminalWindowsShellSection.tsx | 39 +------------------ src/shared/global-settings-types.ts | 2 +- 3 files changed, 2 insertions(+), 40 deletions(-) diff --git a/src/renderer/src/components/settings/TerminalPane.tsx b/src/renderer/src/components/settings/TerminalPane.tsx index eb1c7cd17ef3..c56a0b7e3fdb 100644 --- a/src/renderer/src/components/settings/TerminalPane.tsx +++ b/src/renderer/src/components/settings/TerminalPane.tsx @@ -317,7 +317,6 @@ export function TerminalPane({ windowsShell={windowsShell} gitBashAvailable={gitBashAvailable} cmderAvailable={cmderAvailable} - cmderPath={settings.terminalWindowsCmderPath ?? ''} /> ) : null, matchesSettingsSearch(searchQuery, getTerminalRenderingSearchEntries()) ? ( diff --git a/src/renderer/src/components/settings/TerminalWindowsShellSection.tsx b/src/renderer/src/components/settings/TerminalWindowsShellSection.tsx index 0aef0e934765..80220cdbd4e6 100644 --- a/src/renderer/src/components/settings/TerminalWindowsShellSection.tsx +++ b/src/renderer/src/components/settings/TerminalWindowsShellSection.tsx @@ -9,8 +9,6 @@ import { SettingsSubsectionHeader } from './SettingsFormControls' import { SearchableSetting } from './SearchableSetting' -import { Input } from '../ui/input' -import { refreshWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { translate } from '@/i18n/i18n' import { ShellIcon } from '../tab-bar/shell-icons' @@ -19,7 +17,6 @@ type TerminalWindowsShellSectionProps = { windowsShell: string gitBashAvailable: boolean cmderAvailable: boolean - cmderPath: string } function windowsShellLabel(shell: string, label: string): React.JSX.Element { @@ -35,8 +32,7 @@ export function TerminalWindowsShellSection({ updateSettings, windowsShell, gitBashAvailable, - cmderAvailable, - cmderPath + cmderAvailable }: TerminalWindowsShellSectionProps): React.JSX.Element { const showGitBashOption = gitBashAvailable || windowsShell === WINDOWS_GIT_BASH_SHELL const showCmderOption = cmderAvailable || windowsShell === WINDOWS_CMDER_SHELL @@ -169,39 +165,6 @@ export function TerminalWindowsShellSection({ } /> - - - updateSettings({ terminalWindowsCmderPath: event.target.value.trimStart() }) - } - // Why: availability gates the Cmder options, so re-probe once the path is committed. - onBlur={() => void refreshWindowsTerminalCapabilities()} - className="w-64" - aria-label={translate( - 'auto.components.settings.TerminalPane.cmderPath', - 'Cmder Folder' - )} - spellCheck={false} - /> - } - /> - ) diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 62e3fd1ea5fd..69e549ab0705 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -168,7 +168,7 @@ export type GlobalSettings = NativeChatGlobalSettings & { terminalDefaultShell?: string /** Optional argv passed to the configured Unix shell for ordinary interactive panes. */ terminalDefaultShellArgs?: string[] - /** Windows-only Cmder install folder (holds vendor\init.bat); empty means auto-detect. */ + /** 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 From 4b7aea7f8d27d45a5d9a5cddb05c5d065b474b71 Mon Sep 17 00:00:00 2001 From: B1nh M1nh <43268322+b1nhm1nh@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:28:44 +0700 Subject: [PATCH 4/7] fix(terminal): address Cmder review feedback - Drop a carried ORCA_CMDER_ROOT when the Cmder folder setting is cleared. - Resolve the relay Cmder root from the pane spawn env, not only the relay process env. - Report cmderAvailable through the SSH preflight so Windows SSH hosts offer Cmder. - Add a Cmder card to onboarding, disabled when a saved Cmder default is not installed. - Revert formatting-only edits in app.ts and runtime-api.ts. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/main/cmder.test.ts | 2 ++ src/main/cmder.ts | 6 +++++- src/main/ipc/app.ts | 5 +---- ...ht-remote-windows-terminal-capabilities.ts | 2 ++ src/preload/api/preflight-api.ts | 1 + src/preload/api/runtime-api.ts | 7 +------ src/relay/preflight-handler.test.ts | 10 ++++++++-- src/relay/preflight-handler.ts | 11 ++++------ src/relay/pty-shell-launch.test.ts | 20 +++++++++++++++++++ src/relay/pty-shell-launch.ts | 7 ++++--- .../onboarding/WindowsTerminalStep.tsx | 20 +++++++++++++++++++ 11 files changed, 68 insertions(+), 23 deletions(-) diff --git a/src/main/cmder.test.ts b/src/main/cmder.test.ts index c86ae822aace..829bec0fefb0 100644 --- a/src/main/cmder.test.ts +++ b/src/main/cmder.test.ts @@ -76,5 +76,7 @@ describe('Cmder discovery', () => { 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 index 1d344198c524..e5a5d7676aac 100644 --- a/src/main/cmder.ts +++ b/src/main/cmder.ts @@ -14,8 +14,12 @@ export function applyConfiguredCmderRootEnv( 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 = { ...spawnOptions.env, [ORCA_CMDER_ROOT_ENV]: root } + spawnOptions.env = { ...env, [ORCA_CMDER_ROOT_ENV]: root } + } else if (spawnOptions.env) { + spawnOptions.env = env } } diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 2a74baa31f5d..0a2b8c528166 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -152,10 +152,7 @@ function readCommandStdout( } try { - child = spawn(command, args, { - detached: true, - stdio: ['ignore', 'pipe', 'ignore'] - }) + child = spawn(command, args, { detached: true, stdio: ['ignore', 'pipe', 'ignore'] }) let stdout = '' child.stdout?.setEncoding('utf8') child.stdout?.on('data', (chunk: string) => { 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/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 b0f4db274202..52d9b58d179f 100644 --- a/src/preload/api/runtime-api.ts +++ b/src/preload/api/runtime-api.ts @@ -33,12 +33,7 @@ export type RuntimeApi = { callback: (response: RuntimeRpcResponse) => void ) => Promise getTerminalFitOverrides: () => Promise< - { - ptyId: string - mode: 'mobile-fit' | 'remote-desktop-fit' - cols: number - rows: number - }[] + { ptyId: string; mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number }[] > getTerminalDrivers: () => Promise< { 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-shell-launch.test.ts b/src/relay/pty-shell-launch.test.ts index 9f657105632a..c12d1eeb1ec6 100644 --- a/src/relay/pty-shell-launch.test.ts +++ b/src/relay/pty-shell-launch.test.ts @@ -412,4 +412,24 @@ describe('relay Cmder launch', () => { 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 d56cd1920bf7..065d2887e01a 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -61,8 +61,9 @@ export function resolveRelaySpawnExecutable(shellPath: string): string { return shellBasename(shellPath) === WINDOWS_CMDER_SHELL ? 'cmd.exe' : shellPath } -function getRelayCmderLaunchConfig(): RelayShellLaunchConfig { - const root = resolveCmderRoot() +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 } } @@ -98,7 +99,7 @@ export function getRelayShellLaunchConfig( } if (platform === 'win32') { if (shellName === WINDOWS_CMDER_SHELL) { - return getRelayCmderLaunchConfig() + 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. diff --git a/src/renderer/src/components/onboarding/WindowsTerminalStep.tsx b/src/renderer/src/components/onboarding/WindowsTerminalStep.tsx index ec7b4c4adbb2..8473766fdfd4 100644 --- a/src/renderer/src/components/onboarding/WindowsTerminalStep.tsx +++ b/src/renderer/src/components/onboarding/WindowsTerminalStep.tsx @@ -66,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) => { @@ -117,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 ? [ { From 0c74b862bb8323b03e485e351bf448cdd229f644 Mon Sep 17 00:00:00 2001 From: B1nh M1nh <43268322+b1nhm1nh@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:54:40 +0700 Subject: [PATCH 5/7] fix(terminal): resolve Cmder from spawn env on the in-process fallback orcad's LocalPtyProvider fallback never sets configuredCmderRoot, so read the setting-carried ORCA_CMDER_ROOT from args.env like the daemon path does. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/main/providers/local-pty-launch-plan.ts | 5 ++- ...-pty-provider-windows-shell-launch.test.ts | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/main/providers/local-pty-launch-plan.ts b/src/main/providers/local-pty-launch-plan.ts index 0e3e6330085d..275f1b9a27ec 100644 --- a/src/main/providers/local-pty-launch-plan.ts +++ b/src/main/providers/local-pty-launch-plan.ts @@ -126,7 +126,10 @@ function createWindowsLocalPtyLaunchPlan( } const normalizedShellFamily = pathWin32.basename(shellFamily).toLowerCase() const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellFamily) - const cmderRoot = resolveWindowsCmderShellRoot(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 = 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 f8f095ecd4fc..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 @@ -678,6 +678,40 @@ describe('LocalPtyProvider', () => { ) }) + 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' }) From 8fbcbf5946e6db8590e4ef609d48146639071e2a Mon Sep 17 00:00:00 2001 From: B1nh M1nh <43268322+b1nhm1nh@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:20:52 +0700 Subject: [PATCH 6/7] fix(relay): keep a Cmder pane's root across revive A remote Cmder pane whose CMDER_ROOT came only with its spawn request revived as plain cmd.exe, because serialize() did not save the root. Store it with the pane and feed it back as CMDER_ROOT on revive; discovery still requires vendor\init.bat to exist there. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/relay/pty-handler-revive.test.ts | 47 +++++++++++++++++++++++++++- src/relay/pty-handler.ts | 10 ++++++ 2 files changed, 56 insertions(+), 1 deletion(-) 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 7ced3f74b912..903629d5c54c 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -272,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 @@ -502,6 +504,7 @@ type SerializedPtyEntry = { * default shell, which is what those relays did anyway. */ shellOverride?: string + cmderRoot?: string terminalWindowsWslDistro?: string agentSessionOwners?: AgentSessionOwnerBinding[] } @@ -2218,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, @@ -3187,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 } : {}) }) @@ -3248,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() : '' @@ -3364,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, From 4e4711b98bf2664930abdad77eb2fd060326c0fc Mon Sep 17 00:00:00 2001 From: B1nh M1nh <43268322+b1nhm1nh@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:19:40 +0700 Subject: [PATCH 7/7] test(terminal): give launch plan fixtures a Cmder root Main added these fixtures after the Cmder branch made cmderRoot a required LocalPtyLaunchPlan field. Co-Authored-By: Claude Opus 5.5 --- src/main/opencode/opencode-pty-launch.test.ts | 3 ++- src/main/providers/local-pty-pi-owner-env.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) 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-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())