fix(windows): prevent terminal startup failures
## Bug 详情 - issues.md #46:Windows MSYS2/UCRT64 下执行 csc,欢迎信息输出后在 stdin raw mode/终端输入初始化阶段崩溃,出现 native std::bad_weak_ptr 和后续 Bun mutex corrupt。 - issues.md #47:cmd.exe 可启动,但 PowerShell 运行 csc 会走 npm 生成的 csc.ps1;截图显示 node.exe 报错 "Input must be provided either through stdin or as a prompt argument when using --print",说明裸 csc 被误判成 print/non-interactive 模式。 ## 根因 - MSYS2/Cygwin/mintty 等 Windows 终端对 Node/Bun stdin raw mode 和终端扩展控制序列兼容性不稳定,启动早期强行 setRawMode/查询终端能力会触发崩溃风险。 - npm 的 PowerShell shim 解析优先级高于 csc.cmd,且可能让 stdout TTY 状态不可用;原 main.tsx 只要 stdout 非 TTY 就自动进入 non-interactive/--print 路径,最终在没有 prompt/stdin 时触发 #47 的报错。 ## 修复方案 - 为 Ink stdin raw mode 增加 Windows 风险终端检测,在 MSYS2/Cygwin/mintty 或显式禁用开关下跳过 raw mode,使用 cooked input fallback。 - early input 捕获在启动早期复用同类风险判断,避免 REPL 渲染前就触发 raw mode 崩溃。 - postinstall 在 Windows 上清理属于本包的 csc.ps1,让 PowerShell 回落到与 cmd.exe 一致的 csc.cmd 启动路径。 - 新增 non-interactive 判定工具,保留显式 -p/--print、--init-only、--sdk-url 的 headless 行为,但要求 stdin/stdout 都不是交互 TTY 才自动进入 non-interactive,避免 PowerShell shim 下裸 csc 被误判。 ## 变更要点 - 新增 packages/@ant/ink/src/core/raw-mode-support.ts 及单元测试,覆盖普通 Windows shell、MSYS2/Cygwin/mintty 和禁用开关 - 调整 Ink App raw mode 生命周期,只在真正启用 raw mode 后写入/清理 bracketed paste、focus reporting、extended key 等终端控制序列 - 调整 component unmount/exit 清理逻辑,按 rawModeEnabledCount/rawModeActive 关闭实际启用的输入监听和 raw mode - 调整 src/utils/earlyInput.ts,避免不安全 Windows 终端在启动阶段启用 stdin raw mode - 调整 scripts/postinstall.cjs,立即和延迟清理 npm 生成的 csc.ps1 shim - 调整 src/main.tsx 使用 shouldUseNonInteractiveSession,并新增对应测试覆盖 PowerShell shim 场景 ## 自测 - bun test packages/@ant/ink/src/core/__tests__/raw-mode-support.test.ts src/utils/__tests__/earlyInput.test.ts src/utils/__tests__/nonInteractiveSession.test.ts - node --check scripts/postinstall.cjs - bun run src/entrypoints/cli.tsx --version - bun run lint - git diff --check ## 已知限制 - bun run typecheck 仍失败,错误来自仓库既有缺失声明/缺失模块和无关类型问题,不来自本次改动。
This commit is contained in:
parent
95a4430fa2
commit
223b694965
|
|
@ -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<Props, State> {
|
|||
// 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<Props, State> {
|
|||
|
||||
// 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<Props, State> {
|
|||
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<Props, State> {
|
|||
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<Props, State> {
|
|||
}
|
||||
|
||||
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+<letter> is
|
||||
// distinguishable from ctrl+<letter>. 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+<letter> is
|
||||
// distinguishable from ctrl+<letter>. 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<Props, State> {
|
|||
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<Props, State> {
|
|||
};
|
||||
|
||||
handleExit = (error?: Error): void => {
|
||||
if (this.isRawModeSupported()) {
|
||||
if (this.rawModeEnabledCount > 0) {
|
||||
this.handleSetRawMode(false);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
43
packages/@ant/ink/src/core/raw-mode-support.ts
Normal file
43
packages/@ant/ink/src/core/raw-mode-support.ts
Normal file
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
20
src/main.tsx
20
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) {
|
||||
|
|
|
|||
80
src/utils/__tests__/nonInteractiveSession.test.ts
Normal file
80
src/utils/__tests__/nonInteractiveSession.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
27
src/utils/nonInteractiveSession.ts
Normal file
27
src/utils/nonInteractiveSession.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user