diff --git a/apps/frontend/src/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index 75257eb0..cdc81fd1 100644 --- a/apps/frontend/src/main/terminal/claude-integration-handler.ts +++ b/apps/frontend/src/main/terminal/claude-integration-handler.ts @@ -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(); diff --git a/apps/frontend/src/main/terminal/pty-manager.ts b/apps/frontend/src/main/terminal/pty-manager.ts index 8a9e337a..f1d659f2 100644 --- a/apps/frontend/src/main/terminal/pty-manager.ts +++ b/apps/frontend/src/main/terminal/pty-manager.ts @@ -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 -): 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 }; } /** diff --git a/apps/frontend/src/main/terminal/terminal-lifecycle.ts b/apps/frontend/src/main/terminal/terminal-lifecycle.ts index c85aafe1..142e2005 100644 --- a/apps/frontend/src/main/terminal/terminal-lifecycle.ts +++ b/apps/frontend/src/main/terminal/terminal-lifecycle.ts @@ -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); diff --git a/apps/frontend/src/main/terminal/types.ts b/apps/frontend/src/main/terminal/types.ts index 402bea7a..6585c8aa 100644 --- a/apps/frontend/src/main/terminal/types.ts +++ b/apps/frontend/src/main/terminal/types.ts @@ -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; } /** diff --git a/apps/frontend/src/shared/types/terminal.ts b/apps/frontend/src/shared/types/terminal.ts index 6bb85adb..2d91b71e 100644 --- a/apps/frontend/src/shared/types/terminal.ts +++ b/apps/frontend/src/shared/types/terminal.ts @@ -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; diff --git a/apps/frontend/src/shared/utils/shell-escape.ts b/apps/frontend/src/shared/utils/shell-escape.ts index d826378c..2a17d16b 100644 --- a/apps/frontend/src/shared/utils/shell-escape.ts +++ b/apps/frontend/src/shared/utils/shell-escape.ts @@ -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 '' && " 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 '' && " or "cd ''; " 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)} && `;