From ddb8454c877fb1b27e1269891c75bb92cffedab5 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 14 Apr 2026 20:39:37 +0200 Subject: [PATCH] fix(tui): complete super key helper support closes #2979 --- packages/coding-agent/CHANGELOG.md | 1 + packages/tui/CHANGELOG.md | 4 ++ packages/tui/src/keys.ts | 89 ++++++++++++++++-------------- packages/tui/test/keys.test.ts | 19 ++++++- 4 files changed, 69 insertions(+), 44 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 53aafb40..5b5775af 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Added support for multiple `--append-system-prompt` flags, each value is appended to the system prompt separated by double newlines ([#3169](https://github.com/badlogic/pi-mono/pull/3169) by [@aliou](https://github.com/aliou)) +- Added interactive keybinding support for Kitty `super`-modified shortcuts such as `super+k`, `super+enter`, and `ctrl+super+k` ([#3111](https://github.com/badlogic/pi-mono/pull/3111) by [@sudosubin](https://github.com/sudosubin)) ## [0.67.1] - 2026-04-13 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 7fa93cff..2c329a68 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added Kitty keyboard protocol support for `super`-modified keybindings such as `super+k`, `super+enter`, and `ctrl+super+k`, including typed `Key.super(...)` helpers ([#3111](https://github.com/badlogic/pi-mono/pull/3111) by [@sudosubin](https://github.com/sudosubin)) + ## [0.67.1] - 2026-04-13 ## [0.67.0] - 2026-04-13 diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index 4987ba97..6a6e8983 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -139,28 +139,17 @@ type SpecialKey = | "f12"; type BaseKey = Letter | Digit | SymbolKey | SpecialKey; +type ModifierName = "ctrl" | "shift" | "alt" | "super"; + +type ModifiedKeyId = { + [M in RemainingModifiers]: `${M}+${Key}` | `${M}+${ModifiedKeyId>}`; +}[RemainingModifiers]; /** * Union type of all valid key identifiers. * Provides autocomplete and catches typos at compile time. */ -export type KeyId = - | BaseKey - | `ctrl+${BaseKey}` - | `shift+${BaseKey}` - | `alt+${BaseKey}` - | `ctrl+shift+${BaseKey}` - | `shift+ctrl+${BaseKey}` - | `ctrl+alt+${BaseKey}` - | `alt+ctrl+${BaseKey}` - | `shift+alt+${BaseKey}` - | `alt+shift+${BaseKey}` - | `ctrl+shift+alt+${BaseKey}` - | `ctrl+alt+shift+${BaseKey}` - | `shift+ctrl+alt+${BaseKey}` - | `shift+alt+ctrl+${BaseKey}` - | `alt+ctrl+shift+${BaseKey}` - | `alt+shift+ctrl+${BaseKey}`; +export type KeyId = BaseKey | ModifiedKeyId; /** * Helper object for creating typed key identifiers with autocomplete. @@ -168,8 +157,8 @@ export type KeyId = * Usage: * - Key.escape, Key.enter, Key.tab, etc. for special keys * - Key.backtick, Key.comma, Key.period, etc. for symbol keys - * - Key.ctrl("c"), Key.alt("x") for single modifier - * - Key.ctrlShift("p"), Key.ctrlAlt("x") for combined modifiers + * - Key.ctrl("c"), Key.alt("x"), Key.super("k") for single modifiers + * - Key.ctrlShift("p"), Key.ctrlAlt("x"), Key.ctrlSuper("k") for combined modifiers */ export const Key = { // Special keys @@ -241,6 +230,7 @@ export const Key = { ctrl: (key: K): `ctrl+${K}` => `ctrl+${key}`, shift: (key: K): `shift+${K}` => `shift+${key}`, alt: (key: K): `alt+${K}` => `alt+${key}`, + super: (key: K): `super+${K}` => `super+${key}`, // Combined modifiers ctrlShift: (key: K): `ctrl+shift+${K}` => `ctrl+shift+${key}`, @@ -249,9 +239,16 @@ export const Key = { altCtrl: (key: K): `alt+ctrl+${K}` => `alt+ctrl+${key}`, shiftAlt: (key: K): `shift+alt+${K}` => `shift+alt+${key}`, altShift: (key: K): `alt+shift+${K}` => `alt+shift+${key}`, + ctrlSuper: (key: K): `ctrl+super+${K}` => `ctrl+super+${key}`, + superCtrl: (key: K): `super+ctrl+${K}` => `super+ctrl+${key}`, + shiftSuper: (key: K): `shift+super+${K}` => `shift+super+${key}`, + superShift: (key: K): `super+shift+${K}` => `super+shift+${key}`, + altSuper: (key: K): `alt+super+${K}` => `alt+super+${key}`, + superAlt: (key: K): `super+alt+${K}` => `super+alt+${key}`, // Triple modifiers ctrlShiftAlt: (key: K): `ctrl+shift+alt+${K}` => `ctrl+shift+alt+${key}`, + ctrlShiftSuper: (key: K): `ctrl+shift+super+${K}` => `ctrl+shift+super+${key}`, } as const; // ============================================================================= @@ -296,6 +293,7 @@ const MODIFIERS = { shift: 1, alt: 2, ctrl: 4, + super: 8, } as const; const LOCK_MASK = 64 + 128; // Caps Lock + Num Lock @@ -759,15 +757,18 @@ function matchesPrintableModifyOtherKeys(data: string, expectedKeycode: number, function formatKeyNameWithModifiers(keyName: string, modifier: number): string | undefined { const mods: string[] = []; const effectiveMod = modifier & ~LOCK_MASK; - const supportedModifierMask = MODIFIERS.shift | MODIFIERS.ctrl | MODIFIERS.alt; + const supportedModifierMask = MODIFIERS.shift | MODIFIERS.ctrl | MODIFIERS.alt | MODIFIERS.super; if ((effectiveMod & ~supportedModifierMask) !== 0) return undefined; if (effectiveMod & MODIFIERS.shift) mods.push("shift"); if (effectiveMod & MODIFIERS.ctrl) mods.push("ctrl"); if (effectiveMod & MODIFIERS.alt) mods.push("alt"); + if (effectiveMod & MODIFIERS.super) mods.push("super"); return mods.length > 0 ? `${mods.join("+")}+${keyName}` : keyName; } -function parseKeyId(keyId: string): { key: string; ctrl: boolean; shift: boolean; alt: boolean } | null { +function parseKeyId( + keyId: string, +): { key: string; ctrl: boolean; shift: boolean; alt: boolean; super: boolean } | null { const parts = keyId.toLowerCase().split("+"); const key = parts[parts.length - 1]; if (!key) return null; @@ -776,6 +777,7 @@ function parseKeyId(keyId: string): { key: string; ctrl: boolean; shift: boolean ctrl: parts.includes("ctrl"), shift: parts.includes("shift"), alt: parts.includes("alt"), + super: parts.includes("super"), }; } @@ -788,9 +790,10 @@ function parseKeyId(keyId: string): { key: string; ctrl: boolean; shift: boolean * - Ctrl combinations: "ctrl+c", "ctrl+z", etc. * - Shift combinations: "shift+tab", "shift+enter" * - Alt combinations: "alt+enter", "alt+backspace" - * - Combined modifiers: "shift+ctrl+p", "ctrl+alt+x" + * - Super combinations: "super+k", "super+enter" + * - Combined modifiers: "shift+ctrl+p", "ctrl+alt+x", "ctrl+super+k" * - * Use the Key helper for autocomplete: Key.ctrl("c"), Key.escape, Key.ctrlShift("p") + * Use the Key helper for autocomplete: Key.ctrl("c"), Key.escape, Key.ctrlShift("p"), Key.super("k") * * @param data - Raw input data from terminal * @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c")) @@ -799,11 +802,12 @@ export function matchesKey(data: string, keyId: KeyId): boolean { const parsed = parseKeyId(keyId); if (!parsed) return false; - const { key, ctrl, shift, alt } = parsed; + const { key, ctrl, shift, alt, super: superModifier } = parsed; let modifier = 0; if (shift) modifier |= MODIFIERS.shift; if (alt) modifier |= MODIFIERS.alt; if (ctrl) modifier |= MODIFIERS.ctrl; + if (superModifier) modifier |= MODIFIERS.super; switch (key) { case "escape": @@ -817,10 +821,10 @@ export function matchesKey(data: string, keyId: KeyId): boolean { case "space": if (!_kittyProtocolActive) { - if (ctrl && !alt && !shift && data === "\x00") { + if (modifier === MODIFIERS.ctrl && data === "\x00") { return true; } - if (alt && !ctrl && !shift && data === "\x1b ") { + if (modifier === MODIFIERS.alt && data === "\x1b ") { return true; } } @@ -837,7 +841,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { ); case "tab": - if (shift && !ctrl && !alt) { + if (modifier === MODIFIERS.shift) { return ( data === "\x1b[Z" || matchesKittySequence(data, CODEPOINTS.tab, MODIFIERS.shift) || @@ -854,7 +858,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { case "enter": case "return": - if (shift && !ctrl && !alt) { + if (modifier === MODIFIERS.shift) { // CSI u sequences (standard Kitty protocol) if ( matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.shift) || @@ -874,7 +878,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { } return false; } - if (alt && !ctrl && !shift) { + if (modifier === MODIFIERS.alt) { // CSI u sequences (standard Kitty protocol) if ( matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.alt) || @@ -909,7 +913,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { ); case "backspace": - if (alt && !ctrl && !shift) { + if (modifier === MODIFIERS.alt) { if (data === "\x1b\x7f" || data === "\x1b\b") { return true; } @@ -918,7 +922,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { matchesModifyOtherKeys(data, CODEPOINTS.backspace, MODIFIERS.alt) ); } - if (ctrl && !alt && !shift) { + if (modifier === MODIFIERS.ctrl) { // Legacy raw 0x08 is ambiguous: it can be Ctrl+Backspace on Windows // Terminal or plain Backspace on other terminals, while also // overlapping with Ctrl+H. @@ -1019,7 +1023,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageDown, modifier); case "up": - if (alt && !ctrl && !shift) { + if (modifier === MODIFIERS.alt) { return data === "\x1bp" || matchesKittySequence(data, ARROW_CODEPOINTS.up, MODIFIERS.alt); } if (modifier === 0) { @@ -1034,7 +1038,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { return matchesKittySequence(data, ARROW_CODEPOINTS.up, modifier); case "down": - if (alt && !ctrl && !shift) { + if (modifier === MODIFIERS.alt) { return data === "\x1bn" || matchesKittySequence(data, ARROW_CODEPOINTS.down, MODIFIERS.alt); } if (modifier === 0) { @@ -1049,7 +1053,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { return matchesKittySequence(data, ARROW_CODEPOINTS.down, modifier); case "left": - if (alt && !ctrl && !shift) { + if (modifier === MODIFIERS.alt) { return ( data === "\x1b[1;3D" || (!_kittyProtocolActive && data === "\x1bB") || @@ -1057,7 +1061,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.alt) ); } - if (ctrl && !alt && !shift) { + if (modifier === MODIFIERS.ctrl) { return ( data === "\x1b[1;5D" || matchesLegacyModifierSequence(data, "left", MODIFIERS.ctrl) || @@ -1076,7 +1080,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { return matchesKittySequence(data, ARROW_CODEPOINTS.left, modifier); case "right": - if (alt && !ctrl && !shift) { + if (modifier === MODIFIERS.alt) { return ( data === "\x1b[1;3C" || (!_kittyProtocolActive && data === "\x1bF") || @@ -1084,7 +1088,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.alt) ); } - if (ctrl && !alt && !shift) { + if (modifier === MODIFIERS.ctrl) { return ( data === "\x1b[1;5C" || matchesLegacyModifierSequence(data, "right", MODIFIERS.ctrl) || @@ -1129,19 +1133,20 @@ export function matchesKey(data: string, keyId: KeyId): boolean { const isLetter = key >= "a" && key <= "z"; const isDigit = isDigitKey(key); - if (ctrl && alt && !shift && !_kittyProtocolActive && rawCtrl) { + if (modifier === MODIFIERS.ctrl + MODIFIERS.alt && !_kittyProtocolActive && rawCtrl) { // Legacy: ctrl+alt+key is ESC followed by the control character. // If that legacy form does not match, continue so CSI-u and // modifyOtherKeys sequences from tmux can still be recognized. if (data === `\x1b${rawCtrl}`) return true; } + } - if (alt && !ctrl && !shift && !_kittyProtocolActive && (isLetter || isDigit)) { + if (modifier === MODIFIERS.alt && !_kittyProtocolActive && (isLetter || isDigit)) { // Legacy: alt+letter/digit is ESC followed by the key if (data === `\x1b${key}`) return true; } - if (ctrl && !shift && !alt) { + if (modifier === MODIFIERS.ctrl) { // Legacy: ctrl+key sends the control character if (rawCtrl && data === rawCtrl) return true; return ( @@ -1150,14 +1155,14 @@ export function matchesKey(data: string, keyId: KeyId): boolean { ); } - if (ctrl && shift && !alt) { + if (modifier === MODIFIERS.shift + MODIFIERS.ctrl) { return ( matchesKittySequence(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl) || matchesPrintableModifyOtherKeys(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl) ); } - if (shift && !ctrl && !alt) { + if (modifier === MODIFIERS.shift) { // Legacy: shift+letter produces uppercase if (isLetter && data === key.toUpperCase()) return true; return ( diff --git a/packages/tui/test/keys.test.ts b/packages/tui/test/keys.test.ts index 976d5fc8..d0ba9b16 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 { decodeKittyPrintable, matchesKey, parseKey, setKittyProtocolActive } from "../src/keys.js"; +import { decodeKittyPrintable, Key, matchesKey, parseKey, setKittyProtocolActive } from "../src/keys.js"; function withEnv(name: string, value: string | undefined, fn: () => void): void { const previous = process.env[name]; @@ -66,6 +66,21 @@ describe("matchesKey", () => { setKittyProtocolActive(false); }); + it("should match super-modified Kitty bindings, including combined modifiers", () => { + setKittyProtocolActive(true); + assert.strictEqual(matchesKey("\x1b[107;9u", "super+k"), true); + assert.strictEqual(matchesKey("\x1b[13;9u", "super+enter"), true); + assert.strictEqual(matchesKey("\x1b[107;13u", Key.ctrlSuper("k")), true); + assert.strictEqual(matchesKey("\x1b[107;13u", "ctrl+super+k"), true); + assert.strictEqual(matchesKey("\x1b[107;14u", "ctrl+shift+super+k"), true); + assert.strictEqual(matchesKey("\x1b[107;13u", "super+k"), false); + assert.strictEqual(parseKey("\x1b[107;9u"), "super+k"); + assert.strictEqual(parseKey("\x1b[13;9u"), "super+enter"); + assert.strictEqual(parseKey("\x1b[107;13u"), "ctrl+super+k"); + assert.strictEqual(parseKey("\x1b[107;14u"), "shift+ctrl+super+k"); + setKittyProtocolActive(false); + }); + it("should match digit bindings via Kitty CSI-u", () => { setKittyProtocolActive(true); assert.strictEqual(matchesKey("\x1b[49u", "1"), true); @@ -474,7 +489,7 @@ describe("parseKey", () => { it("should ignore Kitty CSI-u with unsupported modifiers", () => { setKittyProtocolActive(true); - assert.strictEqual(parseKey("\x1b[99;9u"), undefined); + assert.strictEqual(parseKey("\x1b[99;17u"), undefined); setKittyProtocolActive(false); }); });