fix(tui,coding-agent): handle tmux xterm extended keys and warn on tmux setup fixes #1872
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
### Fixed
|
||||
|
||||
- Fixed autocomplete selection ignoring typed text: highlight now follows the first prefix match as the user types, and exact matches are always selected on Enter ([#1931](https://github.com/badlogic/pi-mono/pull/1931) by [@aliou](https://github.com/aliou))
|
||||
- Fixed xterm `modifyOtherKeys` parsing in `matchesKey()` and `parseKey()`, restoring Ctrl-based keybindings and modified Enter keys in tmux when `extended-keys-format` is left at the default `xterm` ([#1872](https://github.com/badlogic/pi-mono/issues/1872))
|
||||
|
||||
## [0.57.0] - 2026-03-07
|
||||
|
||||
|
||||
@@ -470,6 +470,11 @@ interface ParsedKittySequence {
|
||||
eventType: KeyEventType;
|
||||
}
|
||||
|
||||
interface ParsedModifyOtherKeysSequence {
|
||||
codepoint: number;
|
||||
modifier: number;
|
||||
}
|
||||
|
||||
// Store the last parsed event type for isKeyRelease() to query
|
||||
let _lastEventType: KeyEventType = "press";
|
||||
|
||||
@@ -637,19 +642,23 @@ function matchesKittySequence(data: string, expectedCodepoint: number, expectedM
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseModifyOtherKeysSequence(data: string): ParsedModifyOtherKeysSequence | null {
|
||||
const match = data.match(/^\x1b\[27;(\d+);(\d+)~$/);
|
||||
if (!match) return null;
|
||||
const modValue = parseInt(match[1]!, 10);
|
||||
const codepoint = parseInt(match[2]!, 10);
|
||||
return { codepoint, modifier: modValue - 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Match xterm modifyOtherKeys format: CSI 27 ; modifiers ; keycode ~
|
||||
* This is used by terminals when Kitty protocol is not enabled.
|
||||
* Modifier values are 1-indexed: 2=shift, 3=alt, 5=ctrl, etc.
|
||||
*/
|
||||
function matchesModifyOtherKeys(data: string, expectedKeycode: number, expectedModifier: number): boolean {
|
||||
const match = data.match(/^\x1b\[27;(\d+);(\d+)~$/);
|
||||
if (!match) return false;
|
||||
const modValue = parseInt(match[1]!, 10);
|
||||
const keycode = parseInt(match[2]!, 10);
|
||||
// Convert from 1-indexed xterm format to our 0-indexed format
|
||||
const actualMod = modValue - 1;
|
||||
return keycode === expectedKeycode && actualMod === expectedModifier;
|
||||
const parsed = parseModifyOtherKeysSequence(data);
|
||||
if (!parsed) return false;
|
||||
return parsed.codepoint === expectedKeycode && parsed.modifier === expectedModifier;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -797,7 +806,8 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
||||
}
|
||||
return (
|
||||
matchesKittySequence(data, CODEPOINTS.enter, modifier) ||
|
||||
matchesKittySequence(data, CODEPOINTS.kpEnter, modifier)
|
||||
matchesKittySequence(data, CODEPOINTS.kpEnter, modifier) ||
|
||||
matchesModifyOtherKeys(data, CODEPOINTS.enter, modifier)
|
||||
);
|
||||
|
||||
case "backspace":
|
||||
@@ -1012,21 +1022,30 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
||||
if (ctrl && !shift && !alt) {
|
||||
// Legacy: ctrl+key sends the control character
|
||||
if (rawCtrl && data === rawCtrl) return true;
|
||||
return matchesKittySequence(data, codepoint, MODIFIERS.ctrl);
|
||||
return (
|
||||
matchesKittySequence(data, codepoint, MODIFIERS.ctrl) ||
|
||||
matchesModifyOtherKeys(data, codepoint, MODIFIERS.ctrl)
|
||||
);
|
||||
}
|
||||
|
||||
if (ctrl && shift && !alt) {
|
||||
return matchesKittySequence(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl);
|
||||
return (
|
||||
matchesKittySequence(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl) ||
|
||||
matchesModifyOtherKeys(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl)
|
||||
);
|
||||
}
|
||||
|
||||
if (shift && !ctrl && !alt) {
|
||||
// Legacy: shift+letter produces uppercase
|
||||
if (data === key.toUpperCase()) return true;
|
||||
return matchesKittySequence(data, codepoint, MODIFIERS.shift);
|
||||
return (
|
||||
matchesKittySequence(data, codepoint, MODIFIERS.shift) ||
|
||||
matchesModifyOtherKeys(data, codepoint, MODIFIERS.shift)
|
||||
);
|
||||
}
|
||||
|
||||
if (modifier !== 0) {
|
||||
return matchesKittySequence(data, codepoint, modifier);
|
||||
return matchesKittySequence(data, codepoint, modifier) || matchesModifyOtherKeys(data, codepoint, modifier);
|
||||
}
|
||||
|
||||
// Check both raw char and Kitty sequence (needed for release events)
|
||||
@@ -1042,50 +1061,56 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
||||
* @param data - Raw input data from terminal
|
||||
* @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.
|
||||
const isLatinLetter = codepoint >= 97 && codepoint <= 122; // a-z
|
||||
const isKnownSymbol = SYMBOL_KEYS.has(String.fromCharCode(codepoint));
|
||||
const effectiveCodepoint = isLatinLetter || isKnownSymbol ? codepoint : (baseLayoutKey ?? codepoint);
|
||||
|
||||
let keyName: string | undefined;
|
||||
if (effectiveCodepoint === CODEPOINTS.escape) keyName = "escape";
|
||||
else if (effectiveCodepoint === CODEPOINTS.tab) keyName = "tab";
|
||||
else if (effectiveCodepoint === CODEPOINTS.enter || effectiveCodepoint === CODEPOINTS.kpEnter) keyName = "enter";
|
||||
else if (effectiveCodepoint === CODEPOINTS.space) keyName = "space";
|
||||
else if (effectiveCodepoint === CODEPOINTS.backspace) keyName = "backspace";
|
||||
else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.delete) keyName = "delete";
|
||||
else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.insert) keyName = "insert";
|
||||
else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.home) keyName = "home";
|
||||
else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.end) keyName = "end";
|
||||
else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.pageUp) keyName = "pageUp";
|
||||
else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.pageDown) keyName = "pageDown";
|
||||
else if (effectiveCodepoint === ARROW_CODEPOINTS.up) keyName = "up";
|
||||
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 >= 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;
|
||||
}
|
||||
|
||||
export function parseKey(data: string): string | undefined {
|
||||
const kitty = parseKittySequence(data);
|
||||
if (kitty) {
|
||||
const { codepoint, baseLayoutKey, modifier } = kitty;
|
||||
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 formatParsedKey(kitty.codepoint, kitty.modifier, kitty.baseLayoutKey);
|
||||
}
|
||||
|
||||
// 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.
|
||||
const isLatinLetter = codepoint >= 97 && codepoint <= 122; // a-z
|
||||
const isKnownSymbol = SYMBOL_KEYS.has(String.fromCharCode(codepoint));
|
||||
const effectiveCodepoint = isLatinLetter || isKnownSymbol ? codepoint : (baseLayoutKey ?? codepoint);
|
||||
|
||||
let keyName: string | undefined;
|
||||
if (effectiveCodepoint === CODEPOINTS.escape) keyName = "escape";
|
||||
else if (effectiveCodepoint === CODEPOINTS.tab) keyName = "tab";
|
||||
else if (effectiveCodepoint === CODEPOINTS.enter || effectiveCodepoint === CODEPOINTS.kpEnter) keyName = "enter";
|
||||
else if (effectiveCodepoint === CODEPOINTS.space) keyName = "space";
|
||||
else if (effectiveCodepoint === CODEPOINTS.backspace) keyName = "backspace";
|
||||
else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.delete) keyName = "delete";
|
||||
else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.insert) keyName = "insert";
|
||||
else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.home) keyName = "home";
|
||||
else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.end) keyName = "end";
|
||||
else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.pageUp) keyName = "pageUp";
|
||||
else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.pageDown) keyName = "pageDown";
|
||||
else if (effectiveCodepoint === ARROW_CODEPOINTS.up) keyName = "up";
|
||||
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 >= 97 && effectiveCodepoint <= 122) keyName = String.fromCharCode(effectiveCodepoint);
|
||||
else if (SYMBOL_KEYS.has(String.fromCharCode(effectiveCodepoint)))
|
||||
keyName = String.fromCharCode(effectiveCodepoint);
|
||||
|
||||
if (keyName) {
|
||||
return mods.length > 0 ? `${mods.join("+")}+${keyName}` : keyName;
|
||||
}
|
||||
const modifyOtherKeys = parseModifyOtherKeysSequence(data);
|
||||
if (modifyOtherKeys) {
|
||||
return formatParsedKey(modifyOtherKeys.codepoint, modifyOtherKeys.modifier);
|
||||
}
|
||||
|
||||
// Mode-aware legacy sequences
|
||||
|
||||
@@ -118,6 +118,42 @@ describe("matchesKey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("modifyOtherKeys matching", () => {
|
||||
it("should match xterm modifyOtherKeys Ctrl+c", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;5;99~", "ctrl+c"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;5;99~"), "ctrl+c");
|
||||
});
|
||||
|
||||
it("should match xterm modifyOtherKeys Ctrl+d", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;5;100~", "ctrl+d"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;5;100~"), "ctrl+d");
|
||||
});
|
||||
|
||||
it("should match xterm modifyOtherKeys Ctrl+z", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;5;122~", "ctrl+z"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;5;122~"), "ctrl+z");
|
||||
});
|
||||
|
||||
it("should match xterm modifyOtherKeys Enter variants", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;5;13~", "ctrl+enter"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[27;2;13~", "shift+enter"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[27;3;13~", "alt+enter"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;5;13~"), "ctrl+enter");
|
||||
assert.strictEqual(parseKey("\x1b[27;2;13~"), "shift+enter");
|
||||
assert.strictEqual(parseKey("\x1b[27;3;13~"), "alt+enter");
|
||||
});
|
||||
|
||||
it("should match xterm modifyOtherKeys symbol combos", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;5;47~", "ctrl+/"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;5;47~"), "ctrl+/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Legacy key matching", () => {
|
||||
it("should match legacy Ctrl+c", () => {
|
||||
setKittyProtocolActive(false);
|
||||
|
||||
Reference in New Issue
Block a user