diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index 30c7dee28b..d88f7e8ea7 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -1034,8 +1034,16 @@ describe('ShellExecutionService', () => { expect(mockPtySpawn).toHaveBeenCalledWith( 'powershell.exe', - ['-NoProfile', '-Command', 'chcp 65001 >$null;dir "foo bar"'], - expect.any(Object), + [ + '-NoProfile', + '-NonInteractive', + '-Command', + 'chcp 65001 >$null;dir "foo bar"', + ], + expect.objectContaining({ + handleFlowControl: false, + useConpty: true, + }), ); }); @@ -1051,7 +1059,9 @@ describe('ShellExecutionService', () => { '-c', 'trap \'\' HUP; shopt -u promptvars nullglob extglob nocaseglob dotglob; ls "foo bar"', ], - expect.any(Object), + expect.objectContaining({ + handleFlowControl: true, + }), ); }); }); @@ -1644,7 +1654,7 @@ describe('ShellExecutionService child_process fallback', () => { expect(mockCpSpawn).toHaveBeenCalledWith( 'powershell.exe', - ['-NoProfile', '-Command', 'dir "foo bar"'], + ['-NoProfile', '-NonInteractive', '-Command', 'dir "foo bar"'], expect.objectContaining({ shell: false, detached: false, diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index c35669f2f0..2408dc3e11 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -980,6 +980,7 @@ export class ShellExecutionService { cwd: finalCwd, } = prepared; + const isWindowsPlatform = os.platform() === 'win32'; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const ptyProcess = ptyInfo.module.spawn(finalExecutable, finalArgs, { cwd: finalCwd, @@ -987,7 +988,16 @@ export class ShellExecutionService { cols, rows, env: finalEnv, - handleFlowControl: true, + // handleFlowControl intercepts XON/XOFF (Ctrl+S/Q) and prevents them + // from reaching the child. On Windows, the flag can interfere with + // ConPTY's internal input routing and cause interactive TUI tools to + // miss key events, so we disable it there. + handleFlowControl: !isWindowsPlatform, + // On Windows, explicitly request ConPTY (introduced in Windows 10 1809). + // Without this, @lydell/node-pty may silently fall back to WinPTY, which + // has known incompatibilities with interactive Node.js TUI applications + // that rely on VT-sequence-based arrow-key navigation. + ...(isWindowsPlatform ? { useConpty: true } : {}), }); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion @@ -1027,6 +1037,13 @@ export class ShellExecutionService { }).catch(() => {}); }, isActive: () => { + // On Windows, process.kill(pid, 0) can return false negatives + // for ConPTY-managed shell wrappers (powershell.exe), causing + // writeToPty to silently discard input (including arrow keys). + // Check the internal activePtys map first for reliable status. + if (ShellExecutionService.activePtys.has(ptyPid)) { + return true; + } try { return process.kill(ptyPid, 0); } catch { diff --git a/packages/core/src/utils/shell-utils.test.ts b/packages/core/src/utils/shell-utils.test.ts index fc0a056acc..3fe68d2aac 100644 --- a/packages/core/src/utils/shell-utils.test.ts +++ b/packages/core/src/utils/shell-utils.test.ts @@ -488,7 +488,11 @@ describe('getShellConfiguration', () => { it('should return PowerShell configuration by default', () => { const config = getShellConfiguration(); expect(config.executable).toBe('powershell.exe'); - expect(config.argsPrefix).toEqual(['-NoProfile', '-Command']); + expect(config.argsPrefix).toEqual([ + '-NoProfile', + '-NonInteractive', + '-Command', + ]); expect(config.shell).toBe('powershell'); }); @@ -513,7 +517,11 @@ describe('getShellConfiguration', () => { vi.stubEnv('ComSpec', 'C:\\WINDOWS\\system32\\cmd.exe'); const config = getShellConfiguration(); expect(config.executable).toBe('powershell.exe'); - expect(config.argsPrefix).toEqual(['-NoProfile', '-Command']); + expect(config.argsPrefix).toEqual([ + '-NoProfile', + '-NonInteractive', + '-Command', + ]); expect(config.shell).toBe('powershell'); }); @@ -523,7 +531,11 @@ describe('getShellConfiguration', () => { vi.stubEnv('ComSpec', psPath); const config = getShellConfiguration(); expect(config.executable).toBe(psPath); - expect(config.argsPrefix).toEqual(['-NoProfile', '-Command']); + expect(config.argsPrefix).toEqual([ + '-NoProfile', + '-NonInteractive', + '-Command', + ]); expect(config.shell).toBe('powershell'); }); @@ -532,7 +544,11 @@ describe('getShellConfiguration', () => { vi.stubEnv('ComSpec', pwshPath); const config = getShellConfiguration(); expect(config.executable).toBe(pwshPath); - expect(config.argsPrefix).toEqual(['-NoProfile', '-Command']); + expect(config.argsPrefix).toEqual([ + '-NoProfile', + '-NonInteractive', + '-Command', + ]); expect(config.shell).toBe('powershell'); }); @@ -540,7 +556,11 @@ describe('getShellConfiguration', () => { vi.stubEnv('ComSpec', 'C:\\Path\\To\\POWERSHELL.EXE'); const config = getShellConfiguration(); expect(config.executable).toBe('C:\\Path\\To\\POWERSHELL.EXE'); - expect(config.argsPrefix).toEqual(['-NoProfile', '-Command']); + expect(config.argsPrefix).toEqual([ + '-NoProfile', + '-NonInteractive', + '-Command', + ]); expect(config.shell).toBe('powershell'); }); }); diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index f918ebe20e..625d240fcf 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -691,6 +691,11 @@ export function parseCommandDetails( */ export function getShellConfiguration(): ShellConfiguration { if (isWindows()) { + // -NonInteractive prevents PSReadLine from intercepting console input + // events inside the ConPTY session, which otherwise causes interactive + // TUI tools (e.g. pnpm create vite, vim) to receive malformed key events + // and exit when arrow keys are pressed. + const powershellArgsPrefix = ['-NoProfile', '-NonInteractive', '-Command']; const comSpec = process.env['ComSpec']; if (comSpec) { const executable = comSpec.toLowerCase(); @@ -700,7 +705,7 @@ export function getShellConfiguration(): ShellConfiguration { ) { return { executable: comSpec, - argsPrefix: ['-NoProfile', '-Command'], + argsPrefix: powershellArgsPrefix, shell: 'powershell', }; } @@ -718,7 +723,7 @@ export function getShellConfiguration(): ShellConfiguration { // Fall back to Windows PowerShell 5.1 when pwsh.exe is not installed. return { executable: 'powershell.exe', - argsPrefix: ['-NoProfile', '-Command'], + argsPrefix: powershellArgsPrefix, shell: 'powershell', }; }