fix(tui): normalize keypad functional keys closes #2650

This commit is contained in:
Mario Zechner
2026-03-29 21:53:31 +02:00
parent 86d9220dfe
commit 8c64058809
4 changed files with 89 additions and 8 deletions

View File

@@ -22,6 +22,7 @@
- Fixed extension-queued user messages to refresh the interactive pending-message list so messages submitted while a turn is active are no longer silently dropped ([#2674](https://github.com/badlogic/pi-mono/pull/2674) by [@mrexodia](https://github.com/mrexodia))
- Fixed monorepo `tsconfig.json` path mappings to resolve `@mariozechner/pi-ai` subpath exports to source files in development checkouts ([#2625](https://github.com/badlogic/pi-mono/pull/2625) by [@ferologics](https://github.com/ferologics))
- Fixed TUI cell size response handling to consume only exact `CSI 6 ; height ; width t` replies, so bare `Escape` is no longer swallowed while waiting for terminal image metadata ([#2661](https://github.com/badlogic/pi-mono/issues/2661))
- Fixed Kitty keyboard protocol keypad functional keys to normalize to logical digits, symbols, and navigation keys, so numpad input in terminals such as iTerm2 no longer inserts Private Use Area gibberish or gets ignored ([#2650](https://github.com/badlogic/pi-mono/issues/2650))
## [0.63.2] - 2026-03-29

View File

@@ -5,6 +5,7 @@
### Fixed
- Fixed TUI cell size response handling to consume only exact `CSI 6 ; height ; width t` replies, so bare `Escape` is no longer swallowed while waiting for terminal image metadata ([#2661](https://github.com/badlogic/pi-mono/issues/2661))
- Fixed Kitty keyboard protocol keypad functional keys to normalize to logical digits, symbols, and navigation keys, so numpad input in terminals such as iTerm2 no longer inserts Private Use Area gibberish or gets ignored ([#2650](https://github.com/badlogic/pi-mono/issues/2650))
## [0.63.2] - 2026-03-29

View File

@@ -325,6 +325,40 @@ const FUNCTIONAL_CODEPOINTS = {
end: -15,
} as const;
const KITTY_FUNCTIONAL_KEY_EQUIVALENTS = new Map<number, number>([
[57399, 48], // KP_0 -> 0
[57400, 49], // KP_1 -> 1
[57401, 50], // KP_2 -> 2
[57402, 51], // KP_3 -> 3
[57403, 52], // KP_4 -> 4
[57404, 53], // KP_5 -> 5
[57405, 54], // KP_6 -> 6
[57406, 55], // KP_7 -> 7
[57407, 56], // KP_8 -> 8
[57408, 57], // KP_9 -> 9
[57409, 46], // KP_DECIMAL -> .
[57410, 47], // KP_DIVIDE -> /
[57411, 42], // KP_MULTIPLY -> *
[57412, 45], // KP_SUBTRACT -> -
[57413, 43], // KP_ADD -> +
[57415, 61], // KP_EQUAL -> =
[57416, 44], // KP_SEPARATOR -> ,
[57417, ARROW_CODEPOINTS.left],
[57418, ARROW_CODEPOINTS.right],
[57419, ARROW_CODEPOINTS.up],
[57420, ARROW_CODEPOINTS.down],
[57421, FUNCTIONAL_CODEPOINTS.pageUp],
[57422, FUNCTIONAL_CODEPOINTS.pageDown],
[57423, FUNCTIONAL_CODEPOINTS.home],
[57424, FUNCTIONAL_CODEPOINTS.end],
[57425, FUNCTIONAL_CODEPOINTS.insert],
[57426, FUNCTIONAL_CODEPOINTS.delete],
]);
function normalizeKittyFunctionalCodepoint(codepoint: number): number {
return KITTY_FUNCTIONAL_KEY_EQUIVALENTS.get(codepoint) ?? codepoint;
}
const LEGACY_KEY_SEQUENCES = {
up: ["\x1b[A", "\x1bOA"],
down: ["\x1b[B", "\x1bOB"],
@@ -619,8 +653,11 @@ function matchesKittySequence(data: string, expectedCodepoint: number, expectedM
// Check if modifiers match
if (actualMod !== expectedMod) return false;
// Primary match: codepoint matches directly
if (parsed.codepoint === expectedCodepoint) return true;
const normalizedCodepoint = normalizeKittyFunctionalCodepoint(parsed.codepoint);
const normalizedExpectedCodepoint = normalizeKittyFunctionalCodepoint(expectedCodepoint);
// Primary match: codepoint matches directly after normalizing functional keys
if (normalizedCodepoint === normalizedExpectedCodepoint) return true;
// Alternate match: use base layout key for non-Latin keyboard layouts.
// This allows Ctrl+С (Cyrillic) to match Ctrl+c (Latin) when terminal reports
@@ -635,7 +672,7 @@ function matchesKittySequence(data: string, expectedCodepoint: number, expectedM
// (letter remapping) and Ctrl+/ could falsely match Ctrl+[ (symbol remapping)
// if the base layout key were always considered.
if (parsed.baseLayoutKey !== undefined && parsed.baseLayoutKey === expectedCodepoint) {
const cp = parsed.codepoint;
const cp = normalizedCodepoint;
const isLatinLetter = cp >= 97 && cp <= 122; // a-z
const isKnownSymbol = SYMBOL_KEYS.has(String.fromCharCode(cp));
if (!isLatinLetter && !isKnownSymbol) return true;
@@ -1148,15 +1185,18 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
* @returns Key identifier string (e.g., "ctrl+c") or undefined
*/
function formatParsedKey(codepoint: number, modifier: number, baseLayoutKey?: number): string | undefined {
const normalizedCodepoint = normalizeKittyFunctionalCodepoint(codepoint);
// Use base layout key only when codepoint is not a recognized Latin
// letter (a-z), digit (0-9), or symbol (/, -, [, ;, etc.). For those,
// the codepoint is authoritative regardless of physical key position.
// This prevents remapped layouts (Dvorak, Colemak, xremap, etc.) from
// reporting the wrong key name based on the QWERTY physical position.
const isLatinLetter = codepoint >= 97 && codepoint <= 122; // a-z
const isDigit = codepoint >= 48 && codepoint <= 57; // 0-9
const isKnownSymbol = SYMBOL_KEYS.has(String.fromCharCode(codepoint));
const effectiveCodepoint = isLatinLetter || isDigit || isKnownSymbol ? codepoint : (baseLayoutKey ?? codepoint);
const isLatinLetter = normalizedCodepoint >= 97 && normalizedCodepoint <= 122; // a-z
const isDigit = normalizedCodepoint >= 48 && normalizedCodepoint <= 57; // 0-9
const isKnownSymbol = SYMBOL_KEYS.has(String.fromCharCode(normalizedCodepoint));
const effectiveCodepoint =
isLatinLetter || isDigit || isKnownSymbol ? normalizedCodepoint : (baseLayoutKey ?? normalizedCodepoint);
let keyName: string | undefined;
if (effectiveCodepoint === CODEPOINTS.escape) keyName = "escape";
@@ -1304,6 +1344,7 @@ export function decodeKittyPrintable(data: string): string | undefined {
if (modifier & MODIFIERS.shift && typeof shiftedKey === "number") {
effectiveCodepoint = shiftedKey;
}
effectiveCodepoint = normalizeKittyFunctionalCodepoint(effectiveCodepoint);
// Drop control characters or invalid codepoints.
if (!Number.isFinite(effectiveCodepoint) || effectiveCodepoint < 32) return undefined;

View File

@@ -4,7 +4,7 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { matchesKey, parseKey, setKittyProtocolActive } from "../src/keys.js";
import { decodeKittyPrintable, matchesKey, parseKey, setKittyProtocolActive } from "../src/keys.js";
function withEnv(name: string, value: string | undefined, fn: () => void): void {
const previous = process.env[name];
@@ -76,6 +76,29 @@ describe("matchesKey", () => {
setKittyProtocolActive(false);
});
it("should normalize Kitty keypad functional keys to logical digits, symbols, and navigation", () => {
setKittyProtocolActive(true);
assert.strictEqual(matchesKey("\x1b[57400u", "1"), true);
assert.strictEqual(matchesKey("\x1b[57410u", "/"), true);
assert.strictEqual(matchesKey("\x1b[57417u", "left"), true);
assert.strictEqual(matchesKey("\x1b[57426u", "delete"), true);
assert.strictEqual(parseKey("\x1b[57399u"), "0");
assert.strictEqual(parseKey("\x1b[57409u"), ".");
assert.strictEqual(parseKey("\x1b[57413u"), "+");
assert.strictEqual(parseKey("\x1b[57416u"), ",");
assert.strictEqual(parseKey("\x1b[57417u"), "left");
assert.strictEqual(parseKey("\x1b[57418u"), "right");
assert.strictEqual(parseKey("\x1b[57419u"), "up");
assert.strictEqual(parseKey("\x1b[57420u"), "down");
assert.strictEqual(parseKey("\x1b[57421u"), "pageUp");
assert.strictEqual(parseKey("\x1b[57422u"), "pageDown");
assert.strictEqual(parseKey("\x1b[57423u"), "home");
assert.strictEqual(parseKey("\x1b[57424u"), "end");
assert.strictEqual(parseKey("\x1b[57425u"), "insert");
assert.strictEqual(parseKey("\x1b[57426u"), "delete");
setKittyProtocolActive(false);
});
it("should handle shifted key in format", () => {
setKittyProtocolActive(true);
// Format with shifted key: CSI codepoint:shifted:base;modifier u
@@ -389,6 +412,21 @@ describe("matchesKey", () => {
});
});
describe("decodeKittyPrintable", () => {
it("should decode Kitty keypad functional keys to printable characters", () => {
assert.strictEqual(decodeKittyPrintable("\x1b[57399u"), "0");
assert.strictEqual(decodeKittyPrintable("\x1b[57400u"), "1");
assert.strictEqual(decodeKittyPrintable("\x1b[57409u"), ".");
assert.strictEqual(decodeKittyPrintable("\x1b[57410u"), "/");
assert.strictEqual(decodeKittyPrintable("\x1b[57411u"), "*");
assert.strictEqual(decodeKittyPrintable("\x1b[57412u"), "-");
assert.strictEqual(decodeKittyPrintable("\x1b[57413u"), "+");
assert.strictEqual(decodeKittyPrintable("\x1b[57415u"), "=");
assert.strictEqual(decodeKittyPrintable("\x1b[57416u"), ",");
assert.strictEqual(decodeKittyPrintable("\x1b[57417u"), undefined);
});
});
describe("parseKey", () => {
describe("Kitty protocol with alternate keys", () => {
it("should return Latin key name when base layout key is present", () => {