feat(tui): support digit keybindings closes #1905
This commit is contained in:
@@ -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:
|
`modifier+key` where modifiers are `ctrl`, `shift`, `alt` (combinable) and keys are:
|
||||||
|
|
||||||
- **Letters:** `a-z`
|
- **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`
|
- **Special:** `escape`, `esc`, `enter`, `return`, `tab`, `space`, `backspace`, `delete`, `insert`, `clear`, `home`, `end`, `pageUp`, `pageDown`, `up`, `down`, `left`, `right`
|
||||||
- **Function:** `f1`-`f12`
|
- **Function:** `f1`-`f12`
|
||||||
- **Symbols:** `` ` ``, `-`, `=`, `[`, `]`, `\`, `;`, `'`, `,`, `.`, `/`, `!`, `@`, `#`, `$`, `%`, `^`, `&`, `*`, `(`, `)`, `_`, `+`, `|`, `~`, `{`, `}`, `:`, `<`, `>`, `?`
|
- **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
|
## All Actions
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
### Added
|
### 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 `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
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ type Letter =
|
|||||||
| "y"
|
| "y"
|
||||||
| "z";
|
| "z";
|
||||||
|
|
||||||
|
type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
|
||||||
|
|
||||||
type SymbolKey =
|
type SymbolKey =
|
||||||
| "`"
|
| "`"
|
||||||
| "-"
|
| "-"
|
||||||
@@ -136,7 +138,7 @@ type SpecialKey =
|
|||||||
| "f11"
|
| "f11"
|
||||||
| "f12";
|
| "f12";
|
||||||
|
|
||||||
type BaseKey = Letter | SymbolKey | SpecialKey;
|
type BaseKey = Letter | Digit | SymbolKey | SpecialKey;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Union type of all valid key identifiers.
|
* Union type of all valid key identifiers.
|
||||||
@@ -687,6 +689,26 @@ function rawCtrlChar(key: string): string | null {
|
|||||||
return 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 {
|
function parseKeyId(keyId: string): { key: string; ctrl: boolean; shift: boolean; alt: boolean } | null {
|
||||||
const parts = keyId.toLowerCase().split("+");
|
const parts = keyId.toLowerCase().split("+");
|
||||||
const key = parts[parts.length - 1];
|
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
|
// Handle single letter/digit keys and symbols
|
||||||
if (key.length === 1 && ((key >= "a" && key <= "z") || SYMBOL_KEYS.has(key))) {
|
if (key.length === 1 && ((key >= "a" && key <= "z") || isDigitKey(key) || SYMBOL_KEYS.has(key))) {
|
||||||
const codepoint = key.charCodeAt(0);
|
const codepoint = key.charCodeAt(0);
|
||||||
const rawCtrl = rawCtrlChar(key);
|
const rawCtrl = rawCtrlChar(key);
|
||||||
|
const isLetter = key >= "a" && key <= "z";
|
||||||
|
const isDigit = isDigitKey(key);
|
||||||
|
|
||||||
if (ctrl && alt && !shift && !_kittyProtocolActive && rawCtrl) {
|
if (ctrl && alt && !shift && !_kittyProtocolActive && rawCtrl) {
|
||||||
// Legacy: ctrl+alt+key is ESC followed by the control character
|
// Legacy: ctrl+alt+key is ESC followed by the control character
|
||||||
return data === `\x1b${rawCtrl}`;
|
return data === `\x1b${rawCtrl}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (alt && !ctrl && !shift && !_kittyProtocolActive && key >= "a" && key <= "z") {
|
if (alt && !ctrl && !shift && !_kittyProtocolActive && (isLetter || isDigit)) {
|
||||||
// Legacy: alt+letter is ESC followed by the letter
|
// Legacy: alt+letter/digit is ESC followed by the key
|
||||||
if (data === `\x1b${key}`) return true;
|
if (data === `\x1b${key}`) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1024,28 +1048,31 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
|||||||
if (rawCtrl && data === rawCtrl) return true;
|
if (rawCtrl && data === rawCtrl) return true;
|
||||||
return (
|
return (
|
||||||
matchesKittySequence(data, codepoint, MODIFIERS.ctrl) ||
|
matchesKittySequence(data, codepoint, MODIFIERS.ctrl) ||
|
||||||
matchesModifyOtherKeys(data, codepoint, MODIFIERS.ctrl)
|
matchesPrintableModifyOtherKeys(data, codepoint, MODIFIERS.ctrl)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctrl && shift && !alt) {
|
if (ctrl && shift && !alt) {
|
||||||
return (
|
return (
|
||||||
matchesKittySequence(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl) ||
|
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) {
|
if (shift && !ctrl && !alt) {
|
||||||
// Legacy: shift+letter produces uppercase
|
// Legacy: shift+letter produces uppercase
|
||||||
if (data === key.toUpperCase()) return true;
|
if (isLetter && data === key.toUpperCase()) return true;
|
||||||
return (
|
return (
|
||||||
matchesKittySequence(data, codepoint, MODIFIERS.shift) ||
|
matchesKittySequence(data, codepoint, MODIFIERS.shift) ||
|
||||||
matchesModifyOtherKeys(data, codepoint, MODIFIERS.shift)
|
matchesPrintableModifyOtherKeys(data, codepoint, MODIFIERS.shift)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modifier !== 0) {
|
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)
|
// 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
|
* @returns Key identifier string (e.g., "ctrl+c") or undefined
|
||||||
*/
|
*/
|
||||||
function formatParsedKey(codepoint: number, modifier: number, baseLayoutKey?: number): string | 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
|
// Use base layout key only when codepoint is not a recognized Latin
|
||||||
// letter (a-z) or symbol (/, -, [, ;, etc.). For those, the codepoint
|
// letter (a-z), digit (0-9), or symbol (/, -, [, ;, etc.). For those,
|
||||||
// is authoritative regardless of physical key position. This prevents
|
// the codepoint is authoritative regardless of physical key position.
|
||||||
// remapped layouts (Dvorak, Colemak, xremap, etc.) from reporting the
|
// This prevents remapped layouts (Dvorak, Colemak, xremap, etc.) from
|
||||||
// wrong key name based on the QWERTY physical position.
|
// reporting the wrong key name based on the QWERTY physical position.
|
||||||
const isLatinLetter = codepoint >= 97 && codepoint <= 122; // a-z
|
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 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;
|
let keyName: string | undefined;
|
||||||
if (effectiveCodepoint === CODEPOINTS.escape) keyName = "escape";
|
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.down) keyName = "down";
|
||||||
else if (effectiveCodepoint === ARROW_CODEPOINTS.left) keyName = "left";
|
else if (effectiveCodepoint === ARROW_CODEPOINTS.left) keyName = "left";
|
||||||
else if (effectiveCodepoint === ARROW_CODEPOINTS.right) keyName = "right";
|
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 (effectiveCodepoint >= 97 && effectiveCodepoint <= 122) keyName = String.fromCharCode(effectiveCodepoint);
|
||||||
else if (SYMBOL_KEYS.has(String.fromCharCode(effectiveCodepoint))) keyName = String.fromCharCode(effectiveCodepoint);
|
else if (SYMBOL_KEYS.has(String.fromCharCode(effectiveCodepoint))) keyName = String.fromCharCode(effectiveCodepoint);
|
||||||
|
|
||||||
if (!keyName) return undefined;
|
if (!keyName) return undefined;
|
||||||
return mods.length > 0 ? `${mods.join("+")}+${keyName}` : keyName;
|
return formatKeyNameWithModifiers(keyName, modifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseKey(data: string): string | undefined {
|
export function parseKey(data: string): string | undefined {
|
||||||
@@ -1149,8 +1170,8 @@ export function parseKey(data: string): string | undefined {
|
|||||||
if (code >= 1 && code <= 26) {
|
if (code >= 1 && code <= 26) {
|
||||||
return `ctrl+alt+${String.fromCharCode(code + 96)}`;
|
return `ctrl+alt+${String.fromCharCode(code + 96)}`;
|
||||||
}
|
}
|
||||||
// Legacy alt+letter (ESC followed by letter a-z)
|
// Legacy alt+letter/digit (ESC followed by the key)
|
||||||
if (code >= 97 && code <= 122) {
|
if ((code >= 97 && code <= 122) || (code >= 48 && code <= 57)) {
|
||||||
return `alt+${String.fromCharCode(code)}`;
|
return `alt+${String.fromCharCode(code)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ describe("matchesKey", () => {
|
|||||||
setKittyProtocolActive(false);
|
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", () => {
|
it("should handle shifted key in format", () => {
|
||||||
setKittyProtocolActive(true);
|
setKittyProtocolActive(true);
|
||||||
// Format with shifted key: CSI codepoint:shifted:base;modifier u
|
// 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(matchesKey("\x1b[27;5;47~", "ctrl+/"), true);
|
||||||
assert.strictEqual(parseKey("\x1b[27;5;47~"), "ctrl+/");
|
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", () => {
|
describe("Legacy key matching", () => {
|
||||||
@@ -238,6 +256,8 @@ describe("matchesKey", () => {
|
|||||||
assert.strictEqual(parseKey("\x1bF"), "alt+right");
|
assert.strictEqual(parseKey("\x1bF"), "alt+right");
|
||||||
assert.strictEqual(matchesKey("\x1ba", "alt+a"), true);
|
assert.strictEqual(matchesKey("\x1ba", "alt+a"), true);
|
||||||
assert.strictEqual(parseKey("\x1ba"), "alt+a");
|
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(matchesKey("\x1by", "alt+y"), true);
|
||||||
assert.strictEqual(parseKey("\x1by"), "alt+y");
|
assert.strictEqual(parseKey("\x1by"), "alt+y");
|
||||||
assert.strictEqual(matchesKey("\x1bz", "alt+z"), true);
|
assert.strictEqual(matchesKey("\x1bz", "alt+z"), true);
|
||||||
@@ -256,6 +276,8 @@ describe("matchesKey", () => {
|
|||||||
assert.strictEqual(parseKey("\x1bF"), undefined);
|
assert.strictEqual(parseKey("\x1bF"), undefined);
|
||||||
assert.strictEqual(matchesKey("\x1ba", "alt+a"), false);
|
assert.strictEqual(matchesKey("\x1ba", "alt+a"), false);
|
||||||
assert.strictEqual(parseKey("\x1ba"), undefined);
|
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(matchesKey("\x1by", "alt+y"), false);
|
||||||
assert.strictEqual(parseKey("\x1by"), undefined);
|
assert.strictEqual(parseKey("\x1by"), undefined);
|
||||||
setKittyProtocolActive(false);
|
setKittyProtocolActive(false);
|
||||||
@@ -352,6 +374,8 @@ describe("parseKey", () => {
|
|||||||
assert.strictEqual(parseKey("\n"), "enter");
|
assert.strictEqual(parseKey("\n"), "enter");
|
||||||
assert.strictEqual(parseKey("\x00"), "ctrl+space");
|
assert.strictEqual(parseKey("\x00"), "ctrl+space");
|
||||||
assert.strictEqual(parseKey(" "), "space");
|
assert.strictEqual(parseKey(" "), "space");
|
||||||
|
assert.strictEqual(parseKey("1"), "1");
|
||||||
|
assert.strictEqual(matchesKey("1", "1"), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should parse arrow keys", () => {
|
it("should parse arrow keys", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user