From 73dd066ee5384883477d1e9fbebec48976e5a810 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 5 Aug 2026 09:05:20 +0200 Subject: [PATCH] fix(tui): detect Windows Shift+Enter --- packages/tui/CHANGELOG.md | 1 + packages/tui/native/win32/README.md | 22 ++ packages/tui/native/win32/build.mjs | 229 ++++++++++++++++++ .../win32-arm64/win32-console-mode.node | Bin 3072 -> 4096 bytes .../win32-x64/win32-console-mode.node | Bin 3072 -> 4608 bytes .../tui/native/win32/src/win32-console-mode.c | 72 +++++- packages/tui/package.json | 1 + packages/tui/src/native-modifiers.ts | 11 +- packages/tui/src/terminal.ts | 23 +- packages/tui/test/terminal.test.ts | 21 +- 10 files changed, 367 insertions(+), 13 deletions(-) create mode 100644 packages/tui/native/win32/README.md create mode 100644 packages/tui/native/win32/build.mjs diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index bcdf65a24..2777266bb 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed Windows Shift+Enter detection by extending the native Win32 helper to report modifier key state. - Fixed Windows console truecolor detection when Windows Terminal does not provide `WT_SESSION` to child shells. - Fixed terminal width accounting for Indic conjunct grapheme clusters ([#6124](https://github.com/earendil-works/pi/issues/6124) by [@petrroll](https://github.com/petrroll)). - Fixed phantom alternate-screen text selection from unmatched mouse events when changing terminal pane focus. diff --git a/packages/tui/native/win32/README.md b/packages/tui/native/win32/README.md new file mode 100644 index 000000000..48099c7e1 --- /dev/null +++ b/packages/tui/native/win32/README.md @@ -0,0 +1,22 @@ +# Windows native prebuilds + +Build both Windows architectures from the repository root: + +```sh +npm --prefix packages/tui run build:native:win32 +``` + +On Windows, the build uses the Microsoft C++ Build Tools from Visual Studio. `build.mjs` locates `VsDevCmd.bat`, initializes a developer environment for `amd64` and `arm64`, and builds the addon with `cl.exe`/`link.exe`. + +Install the "Desktop development with C++" workload, or at minimum the MSVC toolset and Windows SDK components. No Node headers are required; the addon resolves N-API symbols from the host process. + +For non-Windows cross-builds, or for custom Windows toolchains, set `PI_TUI_WIN32_TOOLCHAIN=mingw` and provide MinGW-compatible compilers: + +```sh +PI_TUI_WIN32_TOOLCHAIN=mingw \ +CC_X64=/path/to/x86_64-w64-mingw32-gcc \ +CC_ARM64=/path/to/aarch64-w64-mingw32-gcc \ +npm --prefix packages/tui run build:native:win32 +``` + +The addon intentionally avoids the C runtime and links only against `kernel32`. diff --git a/packages/tui/native/win32/build.mjs b/packages/tui/native/win32/build.mjs new file mode 100644 index 000000000..eee23fdca --- /dev/null +++ b/packages/tui/native/win32/build.mjs @@ -0,0 +1,229 @@ +import { spawnSync } from "node:child_process"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const sourceFile = path.join(scriptDir, "src", "win32-console-mode.c"); +const temporaryDir = mkdtempSync(path.join(tmpdir(), "pi-tui-win32-")); + +const targets = [ + { + arch: "x64", + msvcArch: "x64", + mingwTarget: "x86_64-w64-windows-gnu", + prefixedCompilers: ["x86_64-w64-mingw32-gcc", "x86_64-w64-mingw32-clang"], + envCompiler: "CC_X64", + }, + { + arch: "arm64", + msvcArch: "arm64", + mingwTarget: "aarch64-w64-windows-gnu", + prefixedCompilers: ["aarch64-w64-mingw32-gcc", "aarch64-w64-mingw32-clang"], + envCompiler: "CC_ARM64", + }, +]; + +function removeTemporaryDir() { + rmSync(temporaryDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +} + +process.on("exit", removeTemporaryDir); +process.on("SIGINT", () => { + removeTemporaryDir(); + process.exit(130); +}); + +function commandExists(command) { + const result = spawnSync(command, ["--version"], { stdio: "ignore" }); + return !result.error || result.error.code !== "ENOENT"; +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { stdio: "inherit", ...options }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`${command} failed with exit code ${result.status ?? 1}`); +} + +function quoteBatch(value) { + return `"${value.replaceAll('"', '""')}"`; +} + +function msvcHostArch() { + return process.arch === "arm64" ? "arm64" : "x64"; +} + +function findVisualStudioDevCmd() { + const candidates = []; + const programFilesX86 = process.env["ProgramFiles(x86)"]; + if (programFilesX86) { + const vswhere = path.join(programFilesX86, "Microsoft Visual Studio", "Installer", "vswhere.exe"); + if (existsSync(vswhere)) { + const result = spawnSync( + vswhere, + [ + "-latest", + "-products", + "*", + "-requires", + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + "-property", + "installationPath", + ], + { encoding: "utf8" }, + ); + for (const line of result.stdout.split(/\r?\n/)) { + const installationPath = line.trim(); + if (installationPath) candidates.push(path.join(installationPath, "Common7", "Tools", "VsDevCmd.bat")); + } + } + } + + for (const base of [process.env.ProgramFiles, programFilesX86]) { + if (!base) continue; + for (const edition of ["BuildTools", "Community", "Professional", "Enterprise"]) { + candidates.push(path.join(base, "Microsoft Visual Studio", "2022", edition, "Common7", "Tools", "VsDevCmd.bat")); + } + } + + return candidates.find((candidate) => existsSync(candidate)); +} + +function canUseMsvc(vsDevCmd) { + const probeDir = path.join(temporaryDir, "msvc-probe"); + mkdirSync(probeDir, { recursive: true }); + const batchFile = path.join(probeDir, "probe.cmd"); + writeFileSync( + batchFile, + [ + "@echo off", + `call ${quoteBatch(vsDevCmd)} -no_logo -arch=x64 -host_arch=${msvcHostArch()} >nul`, + "if errorlevel 1 exit /b %errorlevel%", + "where cl.exe >nul 2>nul", + ].join("\r\n"), + ); + const result = spawnSync("cmd.exe", ["/d", "/s", "/c", batchFile], { cwd: probeDir, stdio: "ignore" }); + return !result.error && result.status === 0; +} + +function resolveMingwCompiler(target) { + const envCompiler = process.env[target.envCompiler]; + if (envCompiler) return { command: envCompiler, useTargetFlag: false }; + if (process.env.CC) return { command: process.env.CC, useTargetFlag: true }; + for (const command of target.prefixedCompilers) { + if (commandExists(command)) return { command, useTargetFlag: false }; + } + if (commandExists("clang")) return { command: "clang", useTargetFlag: true }; + return undefined; +} + +function installOutput(temporaryOutput, target) { + const outputDir = path.join(scriptDir, "prebuilds", `win32-${target.arch}`); + mkdirSync(outputDir, { recursive: true }); + const output = path.join(outputDir, "win32-console-mode.node"); + copyFileSync(temporaryOutput, output); + chmodSync(output, 0o755); + console.log(`Built ${path.relative(process.cwd(), output)}`); +} + +function buildWithMsvc(target, vsDevCmd) { + const buildDir = path.join(temporaryDir, `msvc-${target.arch}`); + const temporaryOutput = path.join(buildDir, "win32-console-mode.node"); + mkdirSync(buildDir, { recursive: true }); + + const batchFile = path.join(buildDir, "build.cmd"); + writeFileSync( + batchFile, + [ + "@echo off", + `call ${quoteBatch(vsDevCmd)} -no_logo -arch=${target.msvcArch} -host_arch=${msvcHostArch()}`, + "if errorlevel 1 exit /b %errorlevel%", + [ + "cl", + "/nologo", + "/TC", + "/std:c11", + "/W4", + "/O1", + "/GS-", + "/LD", + `/Fe:${quoteBatch(temporaryOutput)}`, + quoteBatch(sourceFile), + "/link", + "/NOENTRY", + "/NODEFAULTLIB", + "kernel32.lib", + "/OPT:REF", + "/OPT:ICF", + ].join(" "), + "if errorlevel 1 exit /b %errorlevel%", + ].join("\r\n"), + ); + + run("cmd.exe", ["/d", "/s", "/c", batchFile], { cwd: buildDir }); + installOutput(temporaryOutput, target); +} + +function buildWithMingw(target) { + const compiler = resolveMingwCompiler(target); + if (!compiler) { + throw new Error( + `No Windows cross-compiler found for ${target.arch}. Install Microsoft C++ Build Tools on Windows, or set ${target.envCompiler} to a MinGW-compatible compiler.`, + ); + } + + const buildDir = path.join(temporaryDir, `mingw-${target.arch}`); + const temporaryOutput = path.join(buildDir, "win32-console-mode.node"); + mkdirSync(buildDir, { recursive: true }); + + const args = [ + ...(compiler.useTargetFlag ? [`--target=${target.mingwTarget}`] : []), + "-std=c11", + "-Wall", + "-Wextra", + "-Oz", + "-shared", + "-nostdlib", + "-Wl,--no-entry", + "-Wl,--strip-all", + sourceFile, + "-lkernel32", + "-o", + temporaryOutput, + ]; + + run(compiler.command, args, { cwd: buildDir }); + installOutput(temporaryOutput, target); +} + +if (process.argv.includes("--help") || process.argv.includes("-h")) { + console.log(`Usage: npm --prefix packages/tui run build:native:win32 + +Builds win32-x64 and win32-arm64 native prebuilds. + +Environment: + PI_TUI_WIN32_TOOLCHAIN=msvc|mingw + CC_X64=/path/to/x86_64-w64-mingw32-gcc + CC_ARM64=/path/to/aarch64-w64-mingw32-gcc + CC=/path/to/clang`); + process.exit(0); +} + +const requestedToolchain = process.env.PI_TUI_WIN32_TOOLCHAIN; +if (requestedToolchain && requestedToolchain !== "msvc" && requestedToolchain !== "mingw") { + throw new Error("PI_TUI_WIN32_TOOLCHAIN must be either 'msvc' or 'mingw'"); +} + +const vsDevCmd = findVisualStudioDevCmd(); +const hasMsvc = vsDevCmd ? canUseMsvc(vsDevCmd) : false; +const toolchain = requestedToolchain ?? (process.platform === "win32" && vsDevCmd ? "msvc" : "mingw"); + +if (toolchain === "msvc") { + if (!vsDevCmd || !hasMsvc) { + throw new Error("Microsoft C++ Build Tools not found. Install the Visual Studio C++ workload or set PI_TUI_WIN32_TOOLCHAIN=mingw."); + } + for (const target of targets) buildWithMsvc(target, vsDevCmd); +} else { + for (const target of targets) buildWithMingw(target); +} diff --git a/packages/tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node b/packages/tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node index 42b2c77ccadb03f7ad745fda39583b2e6711d10f..3b408f0ea294061d3eedaa931ecbc95107536a14 100644 GIT binary patch literal 4096 zcmeHJZ-^9S7=LEoJ5N2%)LZ{Rg>!nrDS4RQi9z9PCMlWPtwdC`H@7=?H?cQ+&d!!+ zAKL0m^~s5&gCNp~a3>253+?Y>1rbSUDHH^|YH;*PIRD)GVYlD&&J3<*eF%!6UiO{m zd4KQoJoEf}XWs`N*+YVenlS5iq9dR@bou=A$9LGCv*g1$ba>YBjw8yJ;~hJOt(-RM zWQQDML`xaAoprSVQ*-jRX4%@k+Y;JHHf?sdwaxAfO`lr)#h2cg|4K9`r{A79Taq|gCBpmSs?QJ2&XA3D*x#&yU)2KifVfd3(+ z-Plef-|d>aTr6VEc(BVHdl&~eIW(gFZYOQH2GJgF0svBGHfY1p(GJ~#SP=HAhRp^R za;5IkKv2dAK&Ep}gAiE2`29cr4H=k9d|IrvQnkK;PT*)9oVh@+P6_fgr7)kA^{4BC zj$Xj}^%-=itk4cA)23G|f;`^W73!H3`q8q8`HHe=wcMYlS9!0WsHcYteK=9E7%1=2 zKGpl4XnYbj>n&6zDxCa^=cvOn#i_*S(%0He zOQxmmLyGq+?EQq8HROuaLf^Ob`b43HdMQ@u`v$xQUIX77`BPE6vY7234|O!?9D`iB znR<1|ea8CW`@wfbI*KZFO3*2YAP-7MfPDn49Og!e2iOO|_9~&A5nzXaQG`7kU9E*TMK=!^ZOxJ0FM}BI{bT;{exTqK27jG_`TrA!E3d859g}Z5}&?W$B$(l zFQ?RE?Pm1f=XBx{^29kKMR?Of@i*h$mm|y&9zN~ksC6K-Pxazhb8oi8_Jp9rdCV^K zQ#0n|GgNE>J&V3NgI=wO#eP{V^vmk{(~~WFaS72*niap&*`;4W9(K$-939}nqhNEe4R%8AYQxO65H zt@An4S+n{oa-ZqOb7OYuese708m`H5xnXP24HZ&Z+jX)T%8t5L)+RDC!A>eWGGf?i z%H>B*2eMCD*u=1nQ7buQy2*iTHe(tA*odbFl9oM~mBNkqlSU?QCUdT1*+a>^I~ZQR zq`j15!nNdJ-cAKxgzw4WJ+?7orjxkK!VhR8$OlQQhjm&0yG#>_J zTM|2Nu1OEf&IUM41t-Nz})G>JV9`+Q_Xzs`Q0Omo=QT)Mu z4JtFTX7x(sFNZ{|l=o>aMvx)L9I|q*=_G;YGiLJ1J6MGWf4p%Ql=s5UksyCsaEI)+ z_|DdeZ#>q(aDFJd7e@EkZZo*ovHu)Lu;00R_wMw-5Y~LpjWHhBS`C)IxfWZ)SQ!|X zAe8H2jC*U>RctL|v?{o^b`Ruoa;=AY;r){BMB_Q`O)XkoOaE$r3xN(!xam!3#tgdm z4HavY7)cGFIML6HZFjP%csk8RC%PcEWR3I|Yrrv_F-Y9T5*vb2K^b=LZcbc>+`qAJ z>&7jOXXn}o{^k|>;vITJIS5*$H delta 956 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 QqTj?Nu>|~9``~2YUws%dm;e9( literal 3072 zcmeHIQD_`h6urCK#&qNErmzc93Qp>L1ZkIbH;RHtstJA7t%fA1h`62XjLFFC>@quB zV+4sV5tCshh_w9```-`!sQn7GVhd>vh#J%ng?isadnBG@Sa3 zqt%qEX4`dFnKl%sWh{5L8_C0mjNq4V1aXo(9~{R>Vsy!f4DAALoB#R> zh@}_QUa{n={m+W_6-9VgKi={oxdbjv=^No)L%@h=U-RAjL}v=7cizu^nIT$Sbq72N ze3m)$!!AE}0bu8~*y3s{?)@e@sqf)$ZlD5uh7gF~UG9vk!uv{im;80%G3vSML&7`D zPC>NKDV4KpYau5~9bjS#6Dxq2@V>^bAMmbk@~)lKDcHi>+-$P@DYoWvgR)rs(LKlq zJ|_6UL+$gvSQ=G_%AM4m$W3%|9bSnQO0eRR{{?7g%U)R(<<8E!-1l>)39gnp9x_Do zuKFBk7MNzhGc4Qn(giE{A$6)V=ox_-g0}9+A!cTeqwmzSb}+GlpB4uGcL?vv0zyj-k0m<)xNgb= 1 && args[0]) { + char name[16] = {0}; + unsigned long long copied = 0; + if (napi_get_value_string_utf8(env, args[0], name, sizeof(name), &copied) == 0) { + pressed = is_modifier_name_pressed(name); + } + } + } + + napi_value result = 0; + if (napi_get_boolean) napi_get_boolean(env, pressed, &result); + return result; +} + +static void set_function_export(napi_env env, napi_value exports, const char* name, napi_callback callback) { napi_create_function_fn napi_create_function = (napi_create_function_fn)node_symbol("napi_create_function"); napi_set_named_property_fn napi_set_named_property = (napi_set_named_property_fn)node_symbol("napi_set_named_property"); napi_value fn = 0; if (napi_create_function && napi_set_named_property && - napi_create_function(env, "enableVirtualTerminalInput", NAPI_AUTO_LENGTH, enable_virtual_terminal_input, 0, &fn) == 0) { - napi_set_named_property(env, exports, "enableVirtualTerminalInput", fn); + napi_create_function(env, name, NAPI_AUTO_LENGTH, callback, 0, &fn) == 0) { + napi_set_named_property(env, exports, name, fn); } +} +__declspec(dllexport) napi_value __cdecl napi_register_module_v1(napi_env env, napi_value exports) { + set_function_export(env, exports, "enableVirtualTerminalInput", enable_virtual_terminal_input); + set_function_export(env, exports, "isModifierPressed", is_modifier_pressed); return exports; } diff --git a/packages/tui/package.json b/packages/tui/package.json index d0e528901..ae7003ea5 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -8,6 +8,7 @@ "clean": "shx rm -rf dist", "build": "tsgo -p tsconfig.build.json", "build:native:darwin": "native/darwin/build.sh", + "build:native:win32": "node native/win32/build.mjs", "test": "node --test --test-reporter=dot --test-reporter-destination=stdout test/*.test.ts", "prepublishOnly": "npm run clean && npm run build" }, diff --git a/packages/tui/src/native-modifiers.ts b/packages/tui/src/native-modifiers.ts index e2cd631cb..549ce47a9 100644 --- a/packages/tui/src/native-modifiers.ts +++ b/packages/tui/src/native-modifiers.ts @@ -21,12 +21,19 @@ function isNativeModifiersHelper(value: unknown): value is NativeModifiersHelper function loadNativeModifiersHelper(): NativeModifiersHelper | undefined { if (nativeModifiersHelper !== undefined) return nativeModifiersHelper ?? undefined; nativeModifiersHelper = null; - if (process.platform !== "darwin") return undefined; const arch = process.arch; if (arch !== "x64" && arch !== "arm64") return undefined; + let nativePath: string; + if (process.platform === "darwin") { + nativePath = path.join("native", "darwin", "prebuilds", `darwin-${arch}`, "darwin-modifiers.node"); + } else if (process.platform === "win32") { + nativePath = path.join("native", "win32", "prebuilds", `win32-${arch}`, "win32-console-mode.node"); + } else { + return undefined; + } + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); - const nativePath = path.join("native", "darwin", "prebuilds", `darwin-${arch}`, "darwin-modifiers.node"); const candidates = [ path.join(moduleDir, "..", nativePath), path.join(moduleDir, nativePath), diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 00f6eda24..1014b80fc 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -11,7 +11,7 @@ const cjsRequire = createRequire(import.meta.url); const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000; const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07"; const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0\x07"; -const APPLE_TERMINAL_SHIFT_ENTER_SEQUENCE = "\x1b[13;2u"; +const NATIVE_SHIFT_ENTER_SEQUENCE = "\x1b[13;2u"; const DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS = 7; const KEYBOARD_PROTOCOL_RESPONSE_FRAGMENT_TIMEOUT_MS = 150; const KITTY_KEYBOARD_PROTOCOL_QUERY = `\x1b[>${DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS}u\x1b[?u\x1b[c`; @@ -41,11 +41,19 @@ export function isAppleTerminalSession(): boolean { return process.platform === "darwin" && process.env.TERM_PROGRAM === "Apple_Terminal"; } -export function normalizeAppleTerminalInput(data: string, isAppleTerminal: boolean, isShiftPressed: boolean): string { - if (isAppleTerminal && data === "\r" && isShiftPressed) return APPLE_TERMINAL_SHIFT_ENTER_SEQUENCE; +export function normalizeNativeShiftEnterInput( + data: string, + shouldDetectNativeShiftEnter: boolean, + isShiftPressed: boolean, +): string { + if (shouldDetectNativeShiftEnter && data === "\r" && isShiftPressed) return NATIVE_SHIFT_ENTER_SEQUENCE; return data; } +export function normalizeAppleTerminalInput(data: string, isAppleTerminal: boolean, isShiftPressed: boolean): string { + return normalizeNativeShiftEnterInput(data, isAppleTerminal, isShiftPressed); +} + /** * Minimal terminal interface for TUI */ @@ -308,11 +316,12 @@ export class ProcessTerminal implements Terminal { private forwardInputSequence(sequence: string): void { if (!this.inputHandler) return; - const isAppleTerminal = sequence === "\r" && isAppleTerminalSession(); - const input = normalizeAppleTerminalInput( + const shouldDetectNativeShiftEnter = + sequence === "\r" && (isAppleTerminalSession() || process.platform === "win32"); + const input = normalizeNativeShiftEnterInput( sequence, - isAppleTerminal, - isAppleTerminal && isNativeModifierPressed("shift"), + shouldDetectNativeShiftEnter, + shouldDetectNativeShiftEnter && isNativeModifierPressed("shift"), ); this.inputHandler(input); } diff --git a/packages/tui/test/terminal.test.ts b/packages/tui/test/terminal.test.ts index 0c1290b65..06c751dbc 100644 --- a/packages/tui/test/terminal.test.ts +++ b/packages/tui/test/terminal.test.ts @@ -1,7 +1,26 @@ import assert from "node:assert"; import { describe, it, mock } from "node:test"; import { setKittyProtocolActive } from "../src/keys.ts"; -import { normalizeAppleTerminalInput, ProcessTerminal } from "../src/terminal.ts"; +import { normalizeAppleTerminalInput, normalizeNativeShiftEnterInput, ProcessTerminal } from "../src/terminal.ts"; + +describe("normalizeNativeShiftEnterInput", () => { + it("rewrites Return to CSI-u Shift+Enter when native Shift detection is enabled and Shift is pressed", () => { + assert.equal(normalizeNativeShiftEnterInput("\r", true, true), "\x1b[13;2u"); + }); + + it("leaves Return unchanged when native Shift detection is disabled", () => { + assert.equal(normalizeNativeShiftEnterInput("\r", false, true), "\r"); + }); + + it("leaves Return unchanged when Shift is not pressed", () => { + assert.equal(normalizeNativeShiftEnterInput("\r", true, false), "\r"); + }); + + it("leaves non-Return input unchanged", () => { + assert.equal(normalizeNativeShiftEnterInput("\x1b[13;2u", true, true), "\x1b[13;2u"); + assert.equal(normalizeNativeShiftEnterInput("a", true, true), "a"); + }); +}); describe("normalizeAppleTerminalInput", () => { it("rewrites Apple Terminal Return to CSI-u Shift+Enter when Shift is pressed", () => {