From deb5fb3b88aa38ac282db84a1270942f69092740 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 8 Mar 2026 00:35:08 +0100 Subject: [PATCH] feat(tui): support digit keybindings closes #1905 --- packages/coding-agent/docs/keybindings.md | 3 +- packages/tui/CHANGELOG.md | 1 + packages/tui/src/keys.ts | 73 +++++++++++++++-------- packages/tui/test/keys.test.ts | 24 ++++++++ 4 files changed, 74 insertions(+), 27 deletions(-) diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index 3b8ec6db..52a32c3c 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -7,11 +7,12 @@ All keyboard shortcuts can be customized via `~/.pi/agent/keybindings.json`. Eac `modifier+key` where modifiers are `ctrl`, `shift`, `alt` (combinable) and keys are: - **Letters:** `a-z` +- **Digits:** `0-9` - **Special:** `escape`, `esc`, `enter`, `return`, `tab`, `space`, `backspace`, `delete`, `insert`, `clear`, `home`, `end`, `pageUp`, `pageDown`, `up`, `down`, `left`, `right` - **Function:** `f1`-`f12` - **Symbols:** `` ` ``, `-`, `=`, `[`, `]`, `\`, `;`, `'`, `,`, `.`, `/`, `!`, `@`, `#`, `$`, `%`, `^`, `&`, `*`, `(`, `)`, `_`, `+`, `|`, `~`, `{`, `}`, `:`, `<`, `>`, `?` -Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, etc. +Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `ctrl+1`, etc. ## All Actions diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 5f47ce59..bf6efbf1 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Added `treeFoldOrUp` and `treeUnfoldOrDown` editor actions with default bindings for `Ctrl+←`/`Ctrl+→` and `Alt+←`/`Alt+→` ([#1724](https://github.com/badlogic/pi-mono/pull/1724) by [@Perlence](https://github.com/Perlence)) +- Added digit keys (`0-9`) to the keybinding system, including Kitty CSI-u and xterm `modifyOtherKeys` support for bindings like `ctrl+1` ([#1905](https://github.com/badlogic/pi-mono/issues/1905)) ### Fixed diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index 6dcb4c01..57366f6f 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -71,6 +71,8 @@ type Letter = | "y" | "z"; +type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"; + type SymbolKey = | "`" | "-" @@ -136,7 +138,7 @@ type SpecialKey = | "f11" | "f12"; -type BaseKey = Letter | SymbolKey | SpecialKey; +type BaseKey = Letter | Digit | SymbolKey | SpecialKey; /** * Union type of all valid key identifiers. @@ -687,6 +689,26 @@ function rawCtrlChar(key: string): string | null { return null; } +function isDigitKey(key: string): boolean { + return key >= "0" && key <= "9"; +} + +function matchesPrintableModifyOtherKeys(data: string, expectedKeycode: number, expectedModifier: number): boolean { + if (expectedModifier === 0) return false; + return matchesModifyOtherKeys(data, expectedKeycode, expectedModifier); +} + +function formatKeyNameWithModifiers(keyName: string, modifier: number): string | undefined { + const mods: string[] = []; + const effectiveMod = modifier & ~LOCK_MASK; + const supportedModifierMask = MODIFIERS.shift | MODIFIERS.ctrl | MODIFIERS.alt; + 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"); + return mods.length > 0 ? `${mods.join("+")}+${keyName}` : keyName; +} + function parseKeyId(keyId: string): { key: string; ctrl: boolean; shift: boolean; alt: boolean } | null { const parts = keyId.toLowerCase().split("+"); const key = parts[parts.length - 1]; @@ -1004,18 +1026,20 @@ export function matchesKey(data: string, keyId: KeyId): boolean { } } - // Handle single letter keys (a-z) and some symbols - if (key.length === 1 && ((key >= "a" && key <= "z") || SYMBOL_KEYS.has(key))) { + // Handle single letter/digit keys and symbols + if (key.length === 1 && ((key >= "a" && key <= "z") || isDigitKey(key) || SYMBOL_KEYS.has(key))) { const codepoint = key.charCodeAt(0); const rawCtrl = rawCtrlChar(key); + const isLetter = key >= "a" && key <= "z"; + const isDigit = isDigitKey(key); if (ctrl && alt && !shift && !_kittyProtocolActive && rawCtrl) { // Legacy: ctrl+alt+key is ESC followed by the control character return data === `\x1b${rawCtrl}`; } - if (alt && !ctrl && !shift && !_kittyProtocolActive && key >= "a" && key <= "z") { - // Legacy: alt+letter is ESC followed by the letter + if (alt && !ctrl && !shift && !_kittyProtocolActive && (isLetter || isDigit)) { + // Legacy: alt+letter/digit is ESC followed by the key if (data === `\x1b${key}`) return true; } @@ -1024,28 +1048,31 @@ export function matchesKey(data: string, keyId: KeyId): boolean { if (rawCtrl && data === rawCtrl) return true; return ( matchesKittySequence(data, codepoint, MODIFIERS.ctrl) || - matchesModifyOtherKeys(data, codepoint, MODIFIERS.ctrl) + matchesPrintableModifyOtherKeys(data, codepoint, MODIFIERS.ctrl) ); } if (ctrl && shift && !alt) { return ( matchesKittySequence(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl) || - matchesModifyOtherKeys(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl) + matchesPrintableModifyOtherKeys(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl) ); } if (shift && !ctrl && !alt) { // Legacy: shift+letter produces uppercase - if (data === key.toUpperCase()) return true; + if (isLetter && data === key.toUpperCase()) return true; return ( matchesKittySequence(data, codepoint, MODIFIERS.shift) || - matchesModifyOtherKeys(data, codepoint, MODIFIERS.shift) + matchesPrintableModifyOtherKeys(data, codepoint, MODIFIERS.shift) ); } if (modifier !== 0) { - return matchesKittySequence(data, codepoint, modifier) || matchesModifyOtherKeys(data, codepoint, modifier); + return ( + matchesKittySequence(data, codepoint, modifier) || + matchesPrintableModifyOtherKeys(data, codepoint, modifier) + ); } // Check both raw char and Kitty sequence (needed for release events) @@ -1062,22 +1089,15 @@ 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 mods: string[] = []; - const effectiveMod = modifier & ~LOCK_MASK; - const supportedModifierMask = MODIFIERS.shift | MODIFIERS.ctrl | MODIFIERS.alt; - 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"); - // Use base layout key only when codepoint is not a recognized Latin - // letter (a-z) 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. + // 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 || isKnownSymbol ? codepoint : (baseLayoutKey ?? codepoint); + const effectiveCodepoint = isLatinLetter || isDigit || isKnownSymbol ? codepoint : (baseLayoutKey ?? codepoint); let keyName: string | undefined; if (effectiveCodepoint === CODEPOINTS.escape) keyName = "escape"; @@ -1095,11 +1115,12 @@ function formatParsedKey(codepoint: number, modifier: number, baseLayoutKey?: nu else if (effectiveCodepoint === ARROW_CODEPOINTS.down) keyName = "down"; else if (effectiveCodepoint === ARROW_CODEPOINTS.left) keyName = "left"; else if (effectiveCodepoint === ARROW_CODEPOINTS.right) keyName = "right"; + else if (effectiveCodepoint >= 48 && effectiveCodepoint <= 57) keyName = String.fromCharCode(effectiveCodepoint); else if (effectiveCodepoint >= 97 && effectiveCodepoint <= 122) keyName = String.fromCharCode(effectiveCodepoint); else if (SYMBOL_KEYS.has(String.fromCharCode(effectiveCodepoint))) keyName = String.fromCharCode(effectiveCodepoint); if (!keyName) return undefined; - return mods.length > 0 ? `${mods.join("+")}+${keyName}` : keyName; + return formatKeyNameWithModifiers(keyName, modifier); } export function parseKey(data: string): string | undefined { @@ -1149,8 +1170,8 @@ export function parseKey(data: string): string | undefined { if (code >= 1 && code <= 26) { return `ctrl+alt+${String.fromCharCode(code + 96)}`; } - // Legacy alt+letter (ESC followed by letter a-z) - if (code >= 97 && code <= 122) { + // Legacy alt+letter/digit (ESC followed by the key) + if ((code >= 97 && code <= 122) || (code >= 48 && code <= 57)) { return `alt+${String.fromCharCode(code)}`; } } diff --git a/packages/tui/test/keys.test.ts b/packages/tui/test/keys.test.ts index 38096a4b..e157af9e 100644 --- a/packages/tui/test/keys.test.ts +++ b/packages/tui/test/keys.test.ts @@ -54,6 +54,16 @@ describe("matchesKey", () => { setKittyProtocolActive(false); }); + it("should match digit bindings via Kitty CSI-u", () => { + setKittyProtocolActive(true); + assert.strictEqual(matchesKey("\x1b[49u", "1"), true); + assert.strictEqual(matchesKey("\x1b[49;5u", "ctrl+1"), true); + assert.strictEqual(matchesKey("\x1b[49;5u", "ctrl+2"), false); + assert.strictEqual(parseKey("\x1b[49u"), "1"); + assert.strictEqual(parseKey("\x1b[49;5u"), "ctrl+1"); + setKittyProtocolActive(false); + }); + it("should handle shifted key in format", () => { setKittyProtocolActive(true); // Format with shifted key: CSI codepoint:shifted:base;modifier u @@ -152,6 +162,14 @@ describe("matchesKey", () => { assert.strictEqual(matchesKey("\x1b[27;5;47~", "ctrl+/"), true); assert.strictEqual(parseKey("\x1b[27;5;47~"), "ctrl+/"); }); + + it("should match xterm modifyOtherKeys digit combos", () => { + setKittyProtocolActive(false); + assert.strictEqual(matchesKey("\x1b[27;5;49~", "ctrl+1"), true); + assert.strictEqual(matchesKey("\x1b[27;2;49~", "shift+1"), true); + assert.strictEqual(parseKey("\x1b[27;5;49~"), "ctrl+1"); + assert.strictEqual(parseKey("\x1b[27;2;49~"), "shift+1"); + }); }); describe("Legacy key matching", () => { @@ -238,6 +256,8 @@ describe("matchesKey", () => { assert.strictEqual(parseKey("\x1bF"), "alt+right"); assert.strictEqual(matchesKey("\x1ba", "alt+a"), true); assert.strictEqual(parseKey("\x1ba"), "alt+a"); + assert.strictEqual(matchesKey("\x1b1", "alt+1"), true); + assert.strictEqual(parseKey("\x1b1"), "alt+1"); assert.strictEqual(matchesKey("\x1by", "alt+y"), true); assert.strictEqual(parseKey("\x1by"), "alt+y"); assert.strictEqual(matchesKey("\x1bz", "alt+z"), true); @@ -256,6 +276,8 @@ describe("matchesKey", () => { assert.strictEqual(parseKey("\x1bF"), undefined); assert.strictEqual(matchesKey("\x1ba", "alt+a"), false); assert.strictEqual(parseKey("\x1ba"), undefined); + assert.strictEqual(matchesKey("\x1b1", "alt+1"), false); + assert.strictEqual(parseKey("\x1b1"), undefined); assert.strictEqual(matchesKey("\x1by", "alt+y"), false); assert.strictEqual(parseKey("\x1by"), undefined); setKittyProtocolActive(false); @@ -352,6 +374,8 @@ describe("parseKey", () => { assert.strictEqual(parseKey("\n"), "enter"); assert.strictEqual(parseKey("\x00"), "ctrl+space"); assert.strictEqual(parseKey(" "), "space"); + assert.strictEqual(parseKey("1"), "1"); + assert.strictEqual(matchesKey("1", "1"), true); }); it("should parse arrow keys", () => {