fix(tui): enable VT input mode on Windows (#1495)

This fixes Shift+Tab not being recognized correctly
This commit is contained in:
Duncan Ogilvie 2026-02-14 01:45:11 +01:00 committed by GitHub
parent 6312fc2e42
commit 9e22d3913a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 49 additions and 10 deletions

23
package-lock.json generated
View file

@ -18,7 +18,8 @@
"dependencies": { "dependencies": {
"@mariozechner/jiti": "^2.6.5", "@mariozechner/jiti": "^2.6.5",
"@mariozechner/pi-coding-agent": "^0.30.2", "@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": { "devDependencies": {
"@biomejs/biome": "2.3.5", "@biomejs/biome": "2.3.5",
@ -6120,6 +6121,16 @@
"node": ">= 12" "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": { "node_modules/lie": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", "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", "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz",
"integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==", "integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"peer": true,
"dependencies": { "dependencies": {
"@lit/reactive-element": "^2.1.0", "@lit/reactive-element": "^2.1.0",
"lit-element": "^4.2.0", "lit-element": "^4.2.0",
@ -7742,7 +7752,6 @@
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/dcastil" "url": "https://github.com/sponsors/dcastil"
@ -7771,8 +7780,7 @@
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.3.0", "version": "2.3.0",
@ -7890,7 +7898,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -7987,7 +7994,6 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "~0.27.0", "esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5" "get-tsconfig": "^4.7.5"
@ -8076,7 +8082,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@ -8191,7 +8196,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -8710,6 +8714,7 @@
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"chalk": "^5.5.0", "chalk": "^5.5.0",
"get-east-asian-width": "^1.3.0", "get-east-asian-width": "^1.3.0",
"koffi": "^2.9.0",
"marked": "^15.0.12", "marked": "^15.0.12",
"mime-types": "^3.0.1" "mime-types": "^3.0.1"
}, },

View file

@ -45,7 +45,8 @@
"dependencies": { "dependencies": {
"@mariozechner/jiti": "^2.6.5", "@mariozechner/jiti": "^2.6.5",
"@mariozechner/pi-coding-agent": "^0.30.2", "@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": { "overrides": {
"rimraf": "6.1.2", "rimraf": "6.1.2",

View file

@ -39,6 +39,7 @@
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"chalk": "^5.5.0", "chalk": "^5.5.0",
"get-east-asian-width": "^1.3.0", "get-east-asian-width": "^1.3.0",
"koffi": "^2.9.0",
"marked": "^15.0.12", "marked": "^15.0.12",
"mime-types": "^3.0.1" "mime-types": "^3.0.1"
}, },

View file

@ -1,4 +1,5 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import koffi from "koffi";
import { setKittyProtocolActive } from "./keys.js"; import { setKittyProtocolActive } from "./keys.js";
import { StdinBuffer } from "./stdin-buffer.js"; import { StdinBuffer } from "./stdin-buffer.js";
@ -86,6 +87,12 @@ export class ProcessTerminal implements Terminal {
process.kill(process.pid, "SIGWINCH"); 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 // Query and enable Kitty keyboard protocol
// The query handler intercepts input temporarily, then installs the user's handler // The query handler intercepts input temporarily, then installs the user's handler
// See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
@ -158,6 +165,31 @@ export class ProcessTerminal implements Terminal {
process.stdout.write("\x1b[?u"); 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<void> { async drainInput(maxMs = 1000, idleMs = 50): Promise<void> {
if (this._kittyProtocolActive) { if (this._kittyProtocolActive) {
// Disable Kitty keyboard protocol first so any late key releases // Disable Kitty keyboard protocol first so any late key releases