diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0a6e4344..9ab7f6c2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 525d1d55..33d2ec07 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index a3dc7842..e0d044a3 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -325,6 +325,40 @@ const FUNCTIONAL_CODEPOINTS = { end: -15, } as const; +const KITTY_FUNCTIONAL_KEY_EQUIVALENTS = new Map([ + [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; diff --git a/packages/tui/test/keys.test.ts b/packages/tui/test/keys.test.ts index c944132c..f1e21f37 100644 --- a/packages/tui/test/keys.test.ts +++ b/packages/tui/test/keys.test.ts @@ -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", () => {