fix(tui): complete super key helper support closes #2979

This commit is contained in:
Mario Zechner
2026-04-14 20:39:37 +02:00
parent 6cf5098f6b
commit ddb8454c87
4 changed files with 69 additions and 44 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -139,28 +139,17 @@ type SpecialKey =
| "f12";
type BaseKey = Letter | Digit | SymbolKey | SpecialKey;
type ModifierName = "ctrl" | "shift" | "alt" | "super";
type ModifiedKeyId<Key extends string, RemainingModifiers extends ModifierName = ModifierName> = {
[M in RemainingModifiers]: `${M}+${Key}` | `${M}+${ModifiedKeyId<Key, Exclude<RemainingModifiers, M>>}`;
}[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<BaseKey>;
/**
* 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: <K extends BaseKey>(key: K): `ctrl+${K}` => `ctrl+${key}`,
shift: <K extends BaseKey>(key: K): `shift+${K}` => `shift+${key}`,
alt: <K extends BaseKey>(key: K): `alt+${K}` => `alt+${key}`,
super: <K extends BaseKey>(key: K): `super+${K}` => `super+${key}`,
// Combined modifiers
ctrlShift: <K extends BaseKey>(key: K): `ctrl+shift+${K}` => `ctrl+shift+${key}`,
@@ -249,9 +239,16 @@ export const Key = {
altCtrl: <K extends BaseKey>(key: K): `alt+ctrl+${K}` => `alt+ctrl+${key}`,
shiftAlt: <K extends BaseKey>(key: K): `shift+alt+${K}` => `shift+alt+${key}`,
altShift: <K extends BaseKey>(key: K): `alt+shift+${K}` => `alt+shift+${key}`,
ctrlSuper: <K extends BaseKey>(key: K): `ctrl+super+${K}` => `ctrl+super+${key}`,
superCtrl: <K extends BaseKey>(key: K): `super+ctrl+${K}` => `super+ctrl+${key}`,
shiftSuper: <K extends BaseKey>(key: K): `shift+super+${K}` => `shift+super+${key}`,
superShift: <K extends BaseKey>(key: K): `super+shift+${K}` => `super+shift+${key}`,
altSuper: <K extends BaseKey>(key: K): `alt+super+${K}` => `alt+super+${key}`,
superAlt: <K extends BaseKey>(key: K): `super+alt+${K}` => `super+alt+${key}`,
// Triple modifiers
ctrlShiftAlt: <K extends BaseKey>(key: K): `ctrl+shift+alt+${K}` => `ctrl+shift+alt+${key}`,
ctrlShiftSuper: <K extends BaseKey>(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 (

View File

@@ -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);
});
});