diff --git a/packages/@ant/ink/src/components/App.tsx b/packages/@ant/ink/src/components/App.tsx index bda0c5609..adfcc4857 100644 --- a/packages/@ant/ink/src/components/App.tsx +++ b/packages/@ant/ink/src/components/App.tsx @@ -40,6 +40,7 @@ import { type ParsedMouse, parseMultipleKeypresses, } from '../core/parse-keypress.js'; +import { supportsStdinRawMode } from '../core/raw-mode-support.js'; import reconciler from '../core/reconciler.js'; import { finishSelection, hasSelection, type SelectionState, startSelection } from '../core/selection.js'; import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../core/terminal.js'; @@ -157,6 +158,7 @@ export default class App extends PureComponent { // Count how many components enabled raw mode to avoid disabling // raw mode until all components don't need it anymore rawModeEnabledCount = 0; + rawModeActive = false; internal_eventEmitter = new EventEmitter(); keyParseState = INITIAL_STATE; @@ -195,7 +197,11 @@ export default class App extends PureComponent { // Determines if TTY is supported on the provided stdin isRawModeSupported(): boolean { - return this.props.stdin.isTTY; + return supportsStdinRawMode(this.props.stdin); + } + + isCookedModeFallbackSupported(): boolean { + return this.props.stdin.isTTY && !this.isRawModeSupported(); } override render() { @@ -258,7 +264,7 @@ export default class App extends PureComponent { this.pendingHyperlinkTimer = null; } // ignore calling setRawMode on an handle stdin it cannot be called - if (this.isRawModeSupported()) { + if (this.rawModeEnabledCount > 0) { this.handleSetRawMode(false); } else { // Even when raw mode was never enabled (e.g. non-TTY stdin on @@ -279,7 +285,8 @@ export default class App extends PureComponent { handleSetRawMode = (isEnabled: boolean): void => { const { stdin } = this.props; - if (!this.isRawModeSupported()) { + const rawModeSupported = this.isRawModeSupported(); + if (!rawModeSupported && !this.isCookedModeFallbackSupported()) { if (stdin === process.stdin) { throw new Error( 'Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported', @@ -314,39 +321,50 @@ export default class App extends PureComponent { } stdin.ref(); - stdin.setRawMode(true); - stdin.addListener('readable', this.handleReadable); - // Enable bracketed paste mode - this.props.stdout.write(EBP); - // Enable terminal focus reporting (DECSET 1004) - this.props.stdout.write(EFE); - // Enable extended key reporting so ctrl+shift+ is - // distinguishable from ctrl+. We write both the kitty stack - // push (CSI >1u) and xterm modifyOtherKeys level 2 (CSI >4;2m) — - // terminals honor whichever they implement (tmux only accepts the - // latter). - if (supportsExtendedKeys()) { - this.props.stdout.write(ENABLE_KITTY_KEYBOARD); - this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS); + if (rawModeSupported) { + stdin.setRawMode(true); + this.rawModeActive = true; + } else { + this.rawModeActive = false; + defaultCallbacks.logForDebugging( + '[stdin] Raw mode disabled for this Windows terminal; using cooked input fallback', + { level: 'warn' }, + ); } - // Probe terminal identity. XTVERSION survives SSH (query/reply goes - // through the pty), unlike TERM_PROGRAM. Used for wheel-scroll base - // detection when env vars are absent. Fire-and-forget: the DA1 - // sentinel bounds the round-trip, and if the terminal ignores the - // query, flush() still resolves and name stays undefined. - // Deferred to next tick so it fires AFTER the current synchronous - // init sequence completes — avoids interleaving with alt-screen/mouse - // tracking enable writes that may happen in the same render cycle. - setImmediate(() => { - void Promise.all([this.querier.send(xtversion()), this.querier.flush()]).then(([r]) => { - if (r) { - setXtversionName(r.name); - defaultCallbacks.logForDebugging(`XTVERSION: terminal identified as "${r.name}"`); - } else { - defaultCallbacks.logForDebugging('XTVERSION: no reply (terminal ignored query)'); - } + stdin.addListener('readable', this.handleReadable); + if (rawModeSupported) { + // Enable bracketed paste mode + this.props.stdout.write(EBP); + // Enable terminal focus reporting (DECSET 1004) + this.props.stdout.write(EFE); + // Enable extended key reporting so ctrl+shift+ is + // distinguishable from ctrl+. We write both the kitty stack + // push (CSI >1u) and xterm modifyOtherKeys level 2 (CSI >4;2m) — + // terminals honor whichever they implement (tmux only accepts the + // latter). + if (supportsExtendedKeys()) { + this.props.stdout.write(ENABLE_KITTY_KEYBOARD); + this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS); + } + // Probe terminal identity. XTVERSION survives SSH (query/reply goes + // through the pty), unlike TERM_PROGRAM. Used for wheel-scroll base + // detection when env vars are absent. Fire-and-forget: the DA1 + // sentinel bounds the round-trip, and if the terminal ignores the + // query, flush() still resolves and name stays undefined. + // Deferred to next tick so it fires AFTER the current synchronous + // init sequence completes — avoids interleaving with alt-screen/mouse + // tracking enable writes that may happen in the same render cycle. + setImmediate(() => { + void Promise.all([this.querier.send(xtversion()), this.querier.flush()]).then(([r]) => { + if (r) { + setXtversionName(r.name); + defaultCallbacks.logForDebugging(`XTVERSION: terminal identified as "${r.name}"`); + } else { + defaultCallbacks.logForDebugging('XTVERSION: no reply (terminal ignored query)'); + } + }); }); - }); + } } this.rawModeEnabledCount++; @@ -366,13 +384,16 @@ export default class App extends PureComponent { return; } - this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS); - this.props.stdout.write(DISABLE_KITTY_KEYBOARD); - // Disable terminal focus reporting (DECSET 1004) - this.props.stdout.write(DFE); - // Disable bracketed paste mode - this.props.stdout.write(DBP); - stdin.setRawMode(false); + if (this.rawModeActive) { + this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS); + this.props.stdout.write(DISABLE_KITTY_KEYBOARD); + // Disable terminal focus reporting (DECSET 1004) + this.props.stdout.write(DFE); + // Disable bracketed paste mode + this.props.stdout.write(DBP); + stdin.setRawMode(false); + this.rawModeActive = false; + } stdin.removeListener('readable', this.handleReadable); stdin.unref(); } @@ -480,7 +501,7 @@ export default class App extends PureComponent { }; handleExit = (error?: Error): void => { - if (this.isRawModeSupported()) { + if (this.rawModeEnabledCount > 0) { this.handleSetRawMode(false); } diff --git a/packages/@ant/ink/src/core/__tests__/raw-mode-support.test.ts b/packages/@ant/ink/src/core/__tests__/raw-mode-support.test.ts new file mode 100644 index 000000000..d143573fa --- /dev/null +++ b/packages/@ant/ink/src/core/__tests__/raw-mode-support.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'bun:test'; +import { + isWindowsRawModeUnsafe, + supportsStdinRawMode, +} from '../raw-mode-support.js'; + +describe('isWindowsRawModeUnsafe', () => { + test('does not disable raw mode for ordinary Windows shells', () => { + expect( + isWindowsRawModeUnsafe({ + ComSpec: 'C:\\Windows\\System32\\cmd.exe', + }, 'win32'), + ).toBe(false); + expect( + isWindowsRawModeUnsafe({ + PSModulePath: 'C:\\Program Files\\PowerShell\\Modules', + }, 'win32'), + ).toBe(false); + }); + + test('disables raw mode for MSYS2 and Cygwin terminals', () => { + expect(isWindowsRawModeUnsafe({ MSYSTEM: 'UCRT64' }, 'win32')).toBe(true); + expect(isWindowsRawModeUnsafe({ TERM: 'cygwin' }, 'win32')).toBe(true); + expect(isWindowsRawModeUnsafe({ TERM_PROGRAM: 'mintty' }, 'win32')).toBe( + true, + ); + expect(isWindowsRawModeUnsafe({ SHELL: '/usr/bin/bash' }, 'win32')).toBe( + false, + ); + expect( + isWindowsRawModeUnsafe({ SHELL: '/usr/bin/msys2_shell.cmd' }, 'win32'), + ).toBe(true); + }); + + test('supports an explicit emergency disable switch', () => { + expect( + isWindowsRawModeUnsafe({ + CLAUDE_CODE_DISABLE_STDIN_RAW_MODE: '1', + }, 'win32'), + ).toBe(true); + }); +}); + +describe('supportsStdinRawMode', () => { + test('requires a TTY stream with setRawMode', () => { + expect(supportsStdinRawMode({ isTTY: false } as NodeJS.ReadStream)).toBe( + false, + ); + expect(supportsStdinRawMode({ isTTY: true } as NodeJS.ReadStream)).toBe( + false, + ); + }); +}); diff --git a/packages/@ant/ink/src/core/raw-mode-support.ts b/packages/@ant/ink/src/core/raw-mode-support.ts new file mode 100644 index 000000000..054b8dfca --- /dev/null +++ b/packages/@ant/ink/src/core/raw-mode-support.ts @@ -0,0 +1,43 @@ +function isEnvTruthy(value: string | undefined): boolean { + return value === '1' || value === 'true'; +} + +export function isWindowsRawModeUnsafe( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): boolean { + if (platform !== 'win32') { + return false; + } + + if (isEnvTruthy(env.CLAUDE_CODE_DISABLE_STDIN_RAW_MODE)) { + return true; + } + + const term = env.TERM?.toLowerCase() ?? ''; + const termProgram = env.TERM_PROGRAM?.toLowerCase() ?? ''; + const msystem = env.MSYSTEM?.toLowerCase() ?? ''; + const shell = env.SHELL?.toLowerCase() ?? ''; + + return ( + term === 'cygwin' || + term.includes('mintty') || + termProgram.includes('mintty') || + msystem.length > 0 || + shell.includes('msys') || + shell.includes('cygwin') || + shell.includes('mingw') + ); +} + +export function supportsStdinRawMode(stdin: NodeJS.ReadStream): boolean { + if (!stdin.isTTY) { + return false; + } + + if (isWindowsRawModeUnsafe(process.env, process.platform)) { + return false; + } + + return typeof stdin.setRawMode === 'function'; +} diff --git a/scripts/postinstall.cjs b/scripts/postinstall.cjs index 971b5ad69..0acb7587b 100644 --- a/scripts/postinstall.cjs +++ b/scripts/postinstall.cjs @@ -13,7 +13,7 @@ const { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, chmodSync } = require("fs") -const { spawnSync } = require("child_process") +const { spawn, spawnSync } = require("child_process") const { setDefaultResultOrder } = require("node:dns") const path = require("path") const os = require("os") @@ -97,6 +97,110 @@ function getBinaryPath() { return path.resolve(dir, subdir, binary) } +function safeResolve(...parts) { + const filtered = parts.filter(Boolean) + if (filtered.length === 0) return null + return path.resolve(...filtered) +} + +function removePowerShellShimIfOwned(shimPath) { + if (!shimPath || !existsSync(shimPath)) return false + const cmdShimPath = shimPath.replace(/\.ps1$/i, ".cmd") + if (!existsSync(cmdShimPath)) return false + + let content + try { + content = readFileSync(shimPath, "utf8") + } catch { + return false + } + + // Only remove npm-generated shims for this package. PowerShell resolves + // csc.ps1 before csc.cmd, and enterprise ExecutionPolicy often blocks .ps1 + // launchers while cmd.exe works. Removing this shim makes PowerShell fall + // through to the same .cmd launcher used by cmd.exe. + const normalized = content.replace(/\\/g, "/") + if (!normalized.includes("@costrict") && !normalized.includes("dist/cli-node.js")) { + return false + } + + try { + rmSync(shimPath, { force: true }) + console.log(`[postinstall] Removed PowerShell shim ${shimPath}; PowerShell will use csc.cmd`) + return true + } catch (error) { + console.warn( + `[postinstall] Could not remove PowerShell shim ${shimPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + return false + } +} + +function cleanupWindowsPowerShellShim() { + if (process.platform !== "win32") return + + const dirs = getWindowsPowerShellShimDirs() + const seen = new Set() + for (const dir of dirs) { + const shimPath = path.join(dir, "csc.ps1") + if (seen.has(shimPath)) continue + seen.add(shimPath) + removePowerShellShimIfOwned(shimPath) + } +} + +function getWindowsPowerShellShimDirs() { + return [ + safeResolve(process.env.npm_config_prefix), + safeResolve(process.env.npm_config_local_prefix, "node_modules", ".bin"), + safeResolve(process.env.INIT_CWD, "node_modules", ".bin"), + safeResolve(projectRoot, "node_modules", ".bin"), + ].filter(Boolean) +} + +function scheduleWindowsPowerShellShimCleanup() { + if (process.platform !== "win32") return + + const dirs = getWindowsPowerShellShimDirs() + if (dirs.length === 0) return + + const script = String.raw` +const { existsSync, readFileSync, rmSync } = require("fs"); +const path = require("path"); +const dirs = JSON.parse(process.argv[1] || "[]"); +setTimeout(() => { + for (const dir of dirs) { + const shimPath = path.join(dir, "csc.ps1"); + const cmdShimPath = path.join(dir, "csc.cmd"); + if (!existsSync(shimPath) || !existsSync(cmdShimPath)) continue; + let content = ""; + try { + content = readFileSync(shimPath, "utf8").replace(/\\/g, "/"); + } catch { + continue; + } + if (!content.includes("@costrict") && !content.includes("dist/cli-node.js")) continue; + try { + rmSync(shimPath, { force: true }); + } catch {} + } +}, 3000); +` + + try { + const child = spawn(process.execPath, ["-e", script, JSON.stringify(dirs)], { + detached: true, + stdio: "ignore", + windowsHide: true, + }) + child.unref() + } catch { + // Best effort only. Immediate cleanup above covers the normal npm flow. + } +} + // --- Download helpers --- function proxyEnvSet() { @@ -341,6 +445,8 @@ async function downloadAndExtract() { } async function main() { + cleanupWindowsPowerShellShim() + scheduleWindowsPowerShellShimCleanup() await downloadAndExtract() } diff --git a/src/main.tsx b/src/main.tsx index dc4dcd613..2dd0c0a19 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -233,6 +233,7 @@ import { getGhAuthStatus } from "./utils/github/ghAuthStatus.js"; import { safeParseJSON } from "./utils/json.js"; import { logError } from "./utils/log.js"; import { getModelDeprecationWarning } from "./utils/model/deprecation.js"; +import { shouldUseNonInteractiveSession } from "./utils/nonInteractiveSession.js"; import { getDefaultMainLoopModel, getUserSpecifiedModelSetting, @@ -1134,15 +1135,16 @@ export async function main() { } } - // Check for -p/--print and --init-only flags early to set isInteractiveSession before init() - // This is needed because telemetry initialization calls auth functions that need this flag - const cliArgs = process.argv.slice(2); - const hasPrintFlag = cliArgs.includes("-p") || cliArgs.includes("--print"); - const hasInitOnlyFlag = cliArgs.includes("--init-only"); - const hasSdkUrl = cliArgs.some((arg) => arg.startsWith("--sdk-url")); - const forceInteractive = isEnvTruthy(process.env.CLAUDE_CODE_FORCE_INTERACTIVE); - const isNonInteractive = - hasPrintFlag || hasInitOnlyFlag || hasSdkUrl || (!forceInteractive && !process.stdout.isTTY); + // Check for -p/--print and --init-only flags early to set isInteractiveSession before init() + // This is needed because telemetry initialization calls auth functions that need this flag + const cliArgs = process.argv.slice(2); + const forceInteractive = isEnvTruthy(process.env.CLAUDE_CODE_FORCE_INTERACTIVE); + const isNonInteractive = shouldUseNonInteractiveSession({ + args: cliArgs, + forceInteractive, + stdinIsTTY: process.stdin.isTTY, + stdoutIsTTY: process.stdout.isTTY, + }); // Stop capturing early input for non-interactive modes if (isNonInteractive) { diff --git a/src/utils/__tests__/nonInteractiveSession.test.ts b/src/utils/__tests__/nonInteractiveSession.test.ts new file mode 100644 index 000000000..2235a13bc --- /dev/null +++ b/src/utils/__tests__/nonInteractiveSession.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from 'bun:test' +import { shouldUseNonInteractiveSession } from '../nonInteractiveSession.js' + +describe('shouldUseNonInteractiveSession', () => { + test('uses non-interactive mode for explicit headless flags', () => { + expect( + shouldUseNonInteractiveSession({ + args: ['--print'], + forceInteractive: false, + stdinIsTTY: true, + stdoutIsTTY: true, + }), + ).toBe(true) + expect( + shouldUseNonInteractiveSession({ + args: ['-p'], + forceInteractive: true, + stdinIsTTY: true, + stdoutIsTTY: true, + }), + ).toBe(true) + expect( + shouldUseNonInteractiveSession({ + args: ['--init-only'], + forceInteractive: false, + stdinIsTTY: true, + stdoutIsTTY: true, + }), + ).toBe(true) + expect( + shouldUseNonInteractiveSession({ + args: ['--sdk-url=http://localhost:1234'], + forceInteractive: false, + stdinIsTTY: true, + stdoutIsTTY: true, + }), + ).toBe(true) + }) + + test('does not enter print mode for PowerShell shims that preserve stdin TTY', () => { + expect( + shouldUseNonInteractiveSession({ + args: [], + forceInteractive: false, + stdinIsTTY: true, + stdoutIsTTY: false, + }), + ).toBe(false) + }) + + test('keeps automatic non-interactive mode when both stdio sides are non-TTY', () => { + expect( + shouldUseNonInteractiveSession({ + args: [], + forceInteractive: false, + stdinIsTTY: false, + stdoutIsTTY: false, + }), + ).toBe(true) + expect( + shouldUseNonInteractiveSession({ + args: [], + forceInteractive: false, + stdinIsTTY: undefined, + stdoutIsTTY: undefined, + }), + ).toBe(true) + }) + + test('forceInteractive only suppresses automatic non-interactive detection', () => { + expect( + shouldUseNonInteractiveSession({ + args: [], + forceInteractive: true, + stdinIsTTY: false, + stdoutIsTTY: false, + }), + ).toBe(false) + }) +}) diff --git a/src/utils/earlyInput.ts b/src/utils/earlyInput.ts index 5d20b5b42..2c535c416 100644 --- a/src/utils/earlyInput.ts +++ b/src/utils/earlyInput.ts @@ -13,6 +13,39 @@ import { lastGrapheme } from './intl.js' +function isEnvTruthy(value: string | undefined): boolean { + return value === '1' || value === 'true' +} + +function supportsEarlyInputRawMode(): boolean { + if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') { + return false + } + + if (process.platform !== 'win32') { + return true + } + + if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_STDIN_RAW_MODE)) { + return false + } + + const term = process.env.TERM?.toLowerCase() ?? '' + const termProgram = process.env.TERM_PROGRAM?.toLowerCase() ?? '' + const msystem = process.env.MSYSTEM?.toLowerCase() ?? '' + const shell = process.env.SHELL?.toLowerCase() ?? '' + + return !( + term === 'cygwin' || + term.includes('mintty') || + termProgram.includes('mintty') || + msystem.length > 0 || + shell.includes('msys') || + shell.includes('cygwin') || + shell.includes('mingw') + ) +} + // Buffer for early input characters let earlyInputBuffer = '' // Flag to track if we're currently capturing @@ -33,7 +66,7 @@ export function startCapturingEarlyInput(): void { // be in print mode. Raw mode disables ISIG (terminal Ctrl+C → SIGINT), // which would make -p uninterruptible. if ( - !process.stdin.isTTY || + !supportsEarlyInputRawMode() || isCapturing || process.argv.includes('-p') || process.argv.includes('--print') diff --git a/src/utils/nonInteractiveSession.ts b/src/utils/nonInteractiveSession.ts new file mode 100644 index 000000000..e81eefad7 --- /dev/null +++ b/src/utils/nonInteractiveSession.ts @@ -0,0 +1,27 @@ +export type NonInteractiveSessionOptions = { + args: string[] + forceInteractive: boolean + stdinIsTTY: boolean | undefined + stdoutIsTTY: boolean | undefined +} + +export function shouldUseNonInteractiveSession({ + args, + forceInteractive, + stdinIsTTY, + stdoutIsTTY, +}: NonInteractiveSessionOptions): boolean { + if (args.includes('-p') || args.includes('--print')) return true + if (args.includes('--init-only')) return true + if (args.some(arg => arg.startsWith('--sdk-url'))) return true + + if (forceInteractive) return false + + const hasInteractiveStdin = stdinIsTTY === true + const hasInteractiveStdout = stdoutIsTTY === true + + // Some Windows launchers, notably npm's generated PowerShell .ps1 shim, + // can hide stdout TTY status while stdin is still an interactive terminal. + // Treat that as interactive so a bare `csc` does not fall into --print mode. + return !hasInteractiveStdout && !hasInteractiveStdin +}