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 42b2c77cc..3b408f0ea 100644 Binary files a/packages/tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node and b/packages/tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node differ diff --git a/packages/tui/native/win32/prebuilds/win32-x64/win32-console-mode.node b/packages/tui/native/win32/prebuilds/win32-x64/win32-console-mode.node index 2c6d86d87..911e55d4f 100644 Binary files a/packages/tui/native/win32/prebuilds/win32-x64/win32-console-mode.node and b/packages/tui/native/win32/prebuilds/win32-x64/win32-console-mode.node differ diff --git a/packages/tui/native/win32/src/win32-console-mode.c b/packages/tui/native/win32/src/win32-console-mode.c index d68810c70..9dbc58e9f 100644 --- a/packages/tui/native/win32/src/win32-console-mode.c +++ b/packages/tui/native/win32/src/win32-console-mode.c @@ -5,6 +5,7 @@ #endif #define NAPI_AUTO_LENGTH ((unsigned long long)-1) +#define KEY_PRESSED_MASK 0x8000 typedef void* napi_env; typedef void* napi_value; @@ -13,6 +14,9 @@ typedef napi_value (__cdecl *napi_callback)(napi_env, napi_callback_info); typedef int (__cdecl *napi_create_function_fn)(napi_env, const char*, unsigned long long, napi_callback, void*, napi_value*); typedef int (__cdecl *napi_set_named_property_fn)(napi_env, napi_value, const char*, napi_value); typedef int (__cdecl *napi_get_boolean_fn)(napi_env, int, napi_value*); +typedef int (__cdecl *napi_get_cb_info_fn)(napi_env, napi_callback_info, unsigned long long*, napi_value*, napi_value*, void**); +typedef int (__cdecl *napi_get_value_string_utf8_fn)(napi_env, napi_value, char*, unsigned long long, unsigned long long*); +typedef SHORT (WINAPI *get_async_key_state_fn)(int); static void* node_symbol(const char* name) { HMODULE module = GetModuleHandleA(0); @@ -23,6 +27,41 @@ static void* node_symbol(const char* name) { return module ? (void*)GetProcAddress(module, name) : 0; } +static int string_equals(const char* left, const char* right) { + while (*left && *right && *left == *right) { + left++; + right++; + } + return *left == 0 && *right == 0; +} + +static get_async_key_state_fn get_async_key_state_symbol(void) { + static int loaded = 0; + static get_async_key_state_fn get_async_key_state = 0; + + if (!loaded) { + HMODULE module = GetModuleHandleA("user32.dll"); + if (!module) module = LoadLibraryA("user32.dll"); + get_async_key_state = module ? (get_async_key_state_fn)GetProcAddress(module, "GetAsyncKeyState") : 0; + loaded = 1; + } + + return get_async_key_state; +} + +static int is_key_pressed(int virtual_key) { + get_async_key_state_fn get_async_key_state = get_async_key_state_symbol(); + return get_async_key_state && (((unsigned short)get_async_key_state(virtual_key)) & KEY_PRESSED_MASK) != 0; +} + +static int is_modifier_name_pressed(const char* name) { + if (string_equals(name, "shift")) return is_key_pressed(VK_SHIFT) || is_key_pressed(VK_LSHIFT) || is_key_pressed(VK_RSHIFT); + if (string_equals(name, "control")) return is_key_pressed(VK_CONTROL) || is_key_pressed(VK_LCONTROL) || is_key_pressed(VK_RCONTROL); + if (string_equals(name, "option") || string_equals(name, "alt")) return is_key_pressed(VK_MENU) || is_key_pressed(VK_LMENU) || is_key_pressed(VK_RMENU); + if (string_equals(name, "command") || string_equals(name, "super") || string_equals(name, "win")) return is_key_pressed(VK_LWIN) || is_key_pressed(VK_RWIN); + return 0; +} + static napi_value __cdecl enable_virtual_terminal_input(napi_env env, napi_callback_info info) { (void)info; @@ -38,16 +77,43 @@ static napi_value __cdecl enable_virtual_terminal_input(napi_env env, napi_callb return result; } -__declspec(dllexport) napi_value __cdecl napi_register_module_v1(napi_env env, napi_value exports) { +static napi_value __cdecl is_modifier_pressed(napi_env env, napi_callback_info info) { + napi_get_cb_info_fn napi_get_cb_info = (napi_get_cb_info_fn)node_symbol("napi_get_cb_info"); + napi_get_value_string_utf8_fn napi_get_value_string_utf8 = (napi_get_value_string_utf8_fn)node_symbol("napi_get_value_string_utf8"); + napi_get_boolean_fn napi_get_boolean = (napi_get_boolean_fn)node_symbol("napi_get_boolean"); + + int pressed = 0; + if (napi_get_cb_info && napi_get_value_string_utf8) { + unsigned long long argc = 1; + napi_value args[1] = {0}; + if (napi_get_cb_info(env, info, &argc, args, 0, 0) == 0 && argc >= 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", () => {