fix(windows): use correct command separator for PowerShell terminals (#1159)

PowerShell 5.1 (Windows PowerShell) doesn't support '&&' for command
chaining - it was only added in PowerShell 7+. This caused "Invoke Claude"
to fail with a parse error when the user's terminal is set to PowerShell.

Changes:
- Add WindowsShellType to shared/types/terminal.ts (single source of truth)
- Re-export WindowsShellType from main/terminal/types.ts and shell-escape.ts
- Add detectShellType() in pty-manager to identify PowerShell 5.1 vs others
- Update getWindowsShell() to return WindowsShellResult with shell type
- Update spawnPtyProcess() to return SpawnPtyResult with shellType
- Store shellType on TerminalProcess when spawning terminals
- Update buildCdCommand() to use ';' for PowerShell 5.1, '&&' for others
- Pass terminal's shellType when building cd commands for Claude invocation

The fix is backwards compatible - shellType defaults to undefined which
uses '&&' (same as cmd.exe behavior). Only powershell.exe (PS 5.1) uses
';' - pwsh.exe (PS 7+) supports '&&' so it's treated like cmd.

Fixes #612

Signed-off-by: youngmrz <elliott.zach@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
This commit is contained in:
youngmrz
2026-01-16 17:52:32 -05:00
committed by GitHub
parent 515aada183
commit cb8e46ca2a
6 changed files with 89 additions and 20 deletions
@@ -568,7 +568,7 @@ export function invokeClaude(
isDefault: activeProfile?.isDefault
});
const cwdCommand = buildCdCommand(cwd);
const cwdCommand = buildCdCommand(cwd, terminal.shellType);
const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation();
const escapedClaudeCmd = escapeShellCommand(claudeCmd);
const pathPrefix = buildPathPrefix(claudeEnv.PATH || '');
@@ -775,7 +775,7 @@ export async function invokeClaudeAsync(
});
// Async CLI invocation - non-blocking
const cwdCommand = buildCdCommand(cwd);
const cwdCommand = buildCdCommand(cwd, terminal.shellType);
// Add timeout protection for CLI detection (10s timeout)
const cliInvocationPromise = getClaudeCliInvocationAsync();
+57 -11
View File
@@ -6,7 +6,7 @@
import * as pty from '@lydell/node-pty';
import * as os from 'os';
import { existsSync } from 'fs';
import type { TerminalProcess, WindowGetter } from './types';
import type { TerminalProcess, WindowGetter, WindowsShellType } from './types';
import { isWindows, getWindowsShellPaths } from '../platform';
import { IPC_CHANNELS } from '../../shared/constants';
import { getClaudeProfileManager } from '../claude-profile-manager';
@@ -16,6 +16,23 @@ import type { SupportedTerminal } from '../../shared/types/settings';
// Windows shell paths are now imported from the platform module via getWindowsShellPaths()
/**
* Result of spawning a PTY process
*/
export interface SpawnPtyResult {
pty: pty.IPty;
/** Shell type for Windows (affects command chaining syntax) */
shellType?: WindowsShellType;
}
/**
* Result of Windows shell detection
*/
interface WindowsShellResult {
shell: string;
shellType: WindowsShellType;
}
/**
* Track pending exit promises for terminals being destroyed.
* Used to wait for PTY process exit on Windows where termination is async.
@@ -53,13 +70,31 @@ export function waitForPtyExit(terminalId: string, timeoutMs?: number): Promise<
});
}
/**
* Determine shell type from shell path.
* Only PowerShell 5.1 (powershell.exe) needs special handling with ';' separator.
* PowerShell 7+ (pwsh.exe) supports '&&' like cmd.exe.
*/
function detectShellType(shellPath: string): WindowsShellType {
// Extract just the filename from the path
const filename = shellPath.split(/[/\\]/).pop()?.toLowerCase() || '';
// Only powershell.exe (PS 5.1) needs ';' separator
// pwsh.exe (PS 7+) supports '&&' so we treat it like cmd
if (filename === 'powershell.exe') {
return 'powershell';
}
// Everything else (cmd, pwsh, bash, etc.) uses && syntax
return 'cmd';
}
/**
* Get the Windows shell executable based on preferred terminal setting
*/
function getWindowsShell(preferredTerminal: SupportedTerminal | undefined): string {
function getWindowsShell(preferredTerminal: SupportedTerminal | undefined): WindowsShellResult {
// If no preference or 'system', use COMSPEC (usually cmd.exe)
if (!preferredTerminal || preferredTerminal === 'system') {
return process.env.COMSPEC || 'cmd.exe';
const shell = process.env.COMSPEC || 'cmd.exe';
return { shell, shellType: detectShellType(shell) };
}
// Check if we have paths defined for this terminal type (from platform module)
@@ -69,13 +104,14 @@ function getWindowsShell(preferredTerminal: SupportedTerminal | undefined): stri
// Find the first existing shell
for (const shellPath of paths) {
if (existsSync(shellPath)) {
return shellPath;
return { shell: shellPath, shellType: detectShellType(shellPath) };
}
}
}
// Fallback to COMSPEC for unrecognized terminals
return process.env.COMSPEC || 'cmd.exe';
const shell = process.env.COMSPEC || 'cmd.exe';
return { shell, shellType: detectShellType(shell) };
}
/**
@@ -86,18 +122,26 @@ export function spawnPtyProcess(
cols: number,
rows: number,
profileEnv?: Record<string, string>
): pty.IPty {
): SpawnPtyResult {
// Read user's preferred terminal setting
const settings = readSettingsFile();
const preferredTerminal = settings?.preferredTerminal as SupportedTerminal | undefined;
const shell = isWindows()
? getWindowsShell(preferredTerminal)
: process.env.SHELL || '/bin/zsh';
let shell: string;
let shellType: WindowsShellType | undefined;
if (isWindows()) {
const windowsShell = getWindowsShell(preferredTerminal);
shell = windowsShell.shell;
shellType = windowsShell.shellType;
} else {
shell = process.env.SHELL || '/bin/zsh';
shellType = undefined; // Not applicable on Unix
}
const shellArgs = isWindows() ? [] : ['-l'];
debugLog('[PtyManager] Spawning shell:', shell, shellArgs, '(preferred:', preferredTerminal || 'system', ')');
debugLog('[PtyManager] Spawning shell:', shell, shellArgs, '(preferred:', preferredTerminal || 'system', ', shellType:', shellType, ')');
// Create a clean environment without DEBUG to prevent Claude Code from
// enabling debug mode when the Electron app is run in development mode.
@@ -107,7 +151,7 @@ export function spawnPtyProcess(
// show "Claude API" instead of "Claude Max" when ANTHROPIC_API_KEY is set.
const { DEBUG: _DEBUG, ANTHROPIC_API_KEY: _ANTHROPIC_API_KEY, ...cleanEnv } = process.env;
return pty.spawn(shell, shellArgs, {
const ptyProcess = pty.spawn(shell, shellArgs, {
name: 'xterm-256color',
cols,
rows,
@@ -119,6 +163,8 @@ export function spawnPtyProcess(
COLORTERM: 'truecolor',
},
});
return { pty: ptyProcess, shellType };
}
/**
@@ -67,14 +67,14 @@ export async function createTerminal(
effectiveCwd = projectPath || os.homedir();
}
const ptyProcess = PtyManager.spawnPtyProcess(
const { pty: ptyProcess, shellType } = PtyManager.spawnPtyProcess(
effectiveCwd || os.homedir(),
cols,
rows,
profileEnv
);
debugLog('[TerminalLifecycle] PTY process spawned, pid:', ptyProcess.pid);
debugLog('[TerminalLifecycle] PTY process spawned, pid:', ptyProcess.pid, 'shellType:', shellType);
const terminalCwd = effectiveCwd || os.homedir();
const terminal: TerminalProcess = {
@@ -84,7 +84,8 @@ export async function createTerminal(
projectPath,
cwd: terminalCwd,
outputBuffer: '',
title: `Terminal ${terminals.size + 1}`
title: `Terminal ${terminals.size + 1}`,
shellType
};
terminals.set(id, terminal);
+6 -1
View File
@@ -1,6 +1,9 @@
import type * as pty from '@lydell/node-pty';
import type { BrowserWindow } from 'electron';
import type { TerminalWorktreeConfig } from '../../shared/types';
import type { TerminalWorktreeConfig, WindowsShellType } from '../../shared/types';
// Re-export WindowsShellType for backwards compatibility
export type { WindowsShellType } from '../../shared/types';
/**
* Terminal process tracking
@@ -21,6 +24,8 @@ export interface TerminalProcess {
pendingClaudeResume?: boolean;
/** Whether Claude was invoked with --dangerously-skip-permissions (YOLO mode) */
dangerouslySkipPermissions?: boolean;
/** Shell type for Windows (affects command chaining syntax) */
shellType?: WindowsShellType;
}
/**
@@ -2,6 +2,14 @@
* Terminal-related types
*/
/**
* Shell type for Windows terminals.
* Used to determine correct command chaining syntax:
* - 'powershell': Uses ';' (PowerShell 5.1 doesn't support '&&')
* - 'cmd': Uses '&&' (cmd.exe, PowerShell 7+, bash, etc.)
*/
export type WindowsShellType = 'powershell' | 'cmd';
export interface TerminalCreateOptions {
id: string;
cwd?: string;
+12 -3
View File
@@ -6,6 +6,10 @@
*/
import { isWindows } from '../platform';
import type { WindowsShellType } from '../types/terminal';
// Re-export for convenience
export type { WindowsShellType };
/**
* Escape a string for safe use as a shell argument.
@@ -48,9 +52,11 @@ export function escapeShellPath(path: string): string {
* and uses escapeForWindowsDoubleQuote for proper escaping inside double quotes.
*
* @param path - The directory path
* @returns A safe "cd '<path>' && " string, or empty string if path is undefined
* @param shellType - On Windows, specify 'powershell' or 'cmd' for correct command chaining.
* PowerShell 5.1 doesn't support '&&', so ';' is used instead.
* @returns A safe "cd '<path>' && " or "cd '<path>'; " string, or empty string if path is undefined
*/
export function buildCdCommand(path: string | undefined): string {
export function buildCdCommand(path: string | undefined, shellType?: WindowsShellType): string {
if (!path) {
return '';
}
@@ -61,7 +67,10 @@ export function buildCdCommand(path: string | undefined): string {
// For values inside double quotes, use escapeForWindowsDoubleQuote() because
// caret is literal inside double quotes in cmd.exe (only double quotes need escaping).
const escaped = escapeForWindowsDoubleQuote(path);
return `cd /d "${escaped}" && `;
// PowerShell 5.1 doesn't support '&&' - use ';' instead
// cmd.exe uses '&&' for conditional execution
const separator = shellType === 'powershell' ? '; ' : ' && ';
return `cd /d "${escaped}"${separator}`;
}
return `cd ${escapeShellPath(path)} && `;