diff --git a/package-lock.json b/package-lock.json index 347cbcf3..447d5511 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,8 @@ "dependencies": { "@mariozechner/jiti": "^2.6.5", "@mariozechner/pi-coding-agent": "^0.30.2", - "get-east-asian-width": "^1.4.0" + "get-east-asian-width": "^1.4.0", + "koffi": "^2.15.1" }, "devDependencies": { "@biomejs/biome": "2.3.5", @@ -6120,6 +6121,16 @@ "node": ">= 12" } }, + "node_modules/koffi": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.15.1.tgz", + "integrity": "sha512-mnc0C0crx/xMSljb5s9QbnLrlFHprioFO1hkXyuSuO/QtbpLDa0l/uM21944UfQunMKmp3/r789DTDxVyyH6aA==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -6383,7 +6394,6 @@ "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz", "integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", @@ -7742,7 +7752,6 @@ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -7771,8 +7780,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -7890,7 +7898,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7987,7 +7994,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -8076,7 +8082,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -8191,7 +8196,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8710,6 +8714,7 @@ "@types/mime-types": "^2.1.4", "chalk": "^5.5.0", "get-east-asian-width": "^1.3.0", + "koffi": "^2.9.0", "marked": "^15.0.12", "mime-types": "^3.0.1" }, diff --git a/package.json b/package.json index 2200a9eb..e667b72a 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,8 @@ "dependencies": { "@mariozechner/jiti": "^2.6.5", "@mariozechner/pi-coding-agent": "^0.30.2", - "get-east-asian-width": "^1.4.0" + "get-east-asian-width": "^1.4.0", + "koffi": "^2.15.1" }, "overrides": { "rimraf": "6.1.2", diff --git a/packages/tui/package.json b/packages/tui/package.json index aa672828..2e6f9114 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -39,6 +39,7 @@ "@types/mime-types": "^2.1.4", "chalk": "^5.5.0", "get-east-asian-width": "^1.3.0", + "koffi": "^2.9.0", "marked": "^15.0.12", "mime-types": "^3.0.1" }, diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 9d6846ef..5a83d876 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -1,4 +1,5 @@ import * as fs from "node:fs"; +import koffi from "koffi"; import { setKittyProtocolActive } from "./keys.js"; import { StdinBuffer } from "./stdin-buffer.js"; @@ -86,6 +87,12 @@ export class ProcessTerminal implements Terminal { process.kill(process.pid, "SIGWINCH"); } + // On Windows, enable ENABLE_VIRTUAL_TERMINAL_INPUT so the console sends + // VT escape sequences (e.g. \x1b[Z for Shift+Tab) instead of raw console + // events that lose modifier information. Must run AFTER setRawMode(true) + // since that resets console mode flags. + this.enableWindowsVTInput(); + // Query and enable Kitty keyboard protocol // The query handler intercepts input temporarily, then installs the user's handler // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ @@ -158,6 +165,31 @@ export class ProcessTerminal implements Terminal { process.stdout.write("\x1b[?u"); } + /** + * On Windows, add ENABLE_VIRTUAL_TERMINAL_INPUT (0x0200) to the stdin + * console handle so the terminal sends VT sequences for modified keys + * (e.g. \x1b[Z for Shift+Tab). Without this, libuv's ReadConsoleInputW + * discards modifier state and Shift+Tab arrives as plain \t. + */ + private enableWindowsVTInput(): void { + if (process.platform !== "win32") return; + try { + const k32 = koffi.load("kernel32.dll"); + const GetStdHandle = k32.func("void* __stdcall GetStdHandle(int)"); + const GetConsoleMode = k32.func("bool __stdcall GetConsoleMode(void*, _Out_ uint32_t*)"); + const SetConsoleMode = k32.func("bool __stdcall SetConsoleMode(void*, uint32_t)"); + + const STD_INPUT_HANDLE = -10; + const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200; + const handle = GetStdHandle(STD_INPUT_HANDLE); + const mode = new Uint32Array(1); + GetConsoleMode(handle, mode); + SetConsoleMode(handle, mode[0]! | ENABLE_VIRTUAL_TERMINAL_INPUT); + } catch { + // koffi not available — Shift+Tab won't be distinguishable from Tab + } + } + async drainInput(maxMs = 1000, idleMs = 50): Promise { if (this._kittyProtocolActive) { // Disable Kitty keyboard protocol first so any late key releases