fix(tui): resolve raw backspace ambiguity closes #2293
This commit is contained in:
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed tmux xterm `modifyOtherKeys` matching for `Backspace`, `Escape`, and `Space`, and resolved raw `\x08` backspace ambiguity by treating Windows Terminal sessions differently from legacy terminals ([#2293](https://github.com/badlogic/pi-mono/issues/2293))
|
||||||
|
|
||||||
## [0.59.0] - 2026-03-17
|
## [0.59.0] - 2026-03-17
|
||||||
|
|
||||||
## [0.58.4] - 2026-03-16
|
## [0.58.4] - 2026-03-16
|
||||||
|
|||||||
@@ -663,6 +663,27 @@ function matchesModifyOtherKeys(data: string, expectedKeycode: number, expectedM
|
|||||||
return parsed.codepoint === expectedKeycode && parsed.modifier === expectedModifier;
|
return parsed.codepoint === expectedKeycode && parsed.modifier === expectedModifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isWindowsTerminalSession(): boolean {
|
||||||
|
return (
|
||||||
|
Boolean(process.env.WT_SESSION) && !process.env.SSH_CONNECTION && !process.env.SSH_CLIENT && !process.env.SSH_TTY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw 0x08 (BS) is ambiguous in legacy terminals.
|
||||||
|
*
|
||||||
|
* - Windows Terminal uses it for Ctrl+Backspace.
|
||||||
|
* - Some legacy terminals and tmux setups send it for plain Backspace.
|
||||||
|
*
|
||||||
|
* Prefer explicit Kitty / CSI-u / modifyOtherKeys sequences whenever they are
|
||||||
|
* available. Fall back to a Windows Terminal heuristic only for raw BS bytes.
|
||||||
|
*/
|
||||||
|
function matchesRawBackspace(data: string, expectedModifier: number): boolean {
|
||||||
|
if (data === "\x7f") return expectedModifier === 0;
|
||||||
|
if (data !== "\x08") return false;
|
||||||
|
return isWindowsTerminalSession() ? expectedModifier === MODIFIERS.ctrl : expectedModifier === 0;
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Generic Key Matching
|
// Generic Key Matching
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -751,7 +772,11 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
|||||||
case "escape":
|
case "escape":
|
||||||
case "esc":
|
case "esc":
|
||||||
if (modifier !== 0) return false;
|
if (modifier !== 0) return false;
|
||||||
return data === "\x1b" || matchesKittySequence(data, CODEPOINTS.escape, 0);
|
return (
|
||||||
|
data === "\x1b" ||
|
||||||
|
matchesKittySequence(data, CODEPOINTS.escape, 0) ||
|
||||||
|
matchesModifyOtherKeys(data, CODEPOINTS.escape, 0)
|
||||||
|
);
|
||||||
|
|
||||||
case "space":
|
case "space":
|
||||||
if (!_kittyProtocolActive) {
|
if (!_kittyProtocolActive) {
|
||||||
@@ -763,9 +788,16 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (modifier === 0) {
|
if (modifier === 0) {
|
||||||
return data === " " || matchesKittySequence(data, CODEPOINTS.space, 0);
|
return (
|
||||||
|
data === " " ||
|
||||||
|
matchesKittySequence(data, CODEPOINTS.space, 0) ||
|
||||||
|
matchesModifyOtherKeys(data, CODEPOINTS.space, 0)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return matchesKittySequence(data, CODEPOINTS.space, modifier);
|
return (
|
||||||
|
matchesKittySequence(data, CODEPOINTS.space, modifier) ||
|
||||||
|
matchesModifyOtherKeys(data, CODEPOINTS.space, modifier)
|
||||||
|
);
|
||||||
|
|
||||||
case "tab":
|
case "tab":
|
||||||
if (shift && !ctrl && !alt) {
|
if (shift && !ctrl && !alt) {
|
||||||
@@ -844,18 +876,32 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
|||||||
if (data === "\x1b\x7f" || data === "\x1b\b") {
|
if (data === "\x1b\x7f" || data === "\x1b\b") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.alt);
|
return (
|
||||||
|
matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.alt) ||
|
||||||
|
matchesModifyOtherKeys(data, CODEPOINTS.backspace, MODIFIERS.alt)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (ctrl && !alt && !shift) {
|
if (ctrl && !alt && !shift) {
|
||||||
// Legacy: 0x08 (BS) is sent by Windows Terminal for Ctrl+Backspace.
|
// Legacy raw 0x08 is ambiguous: it can be Ctrl+Backspace on Windows
|
||||||
// Also matches Ctrl+H (same byte, ambiguous in legacy terminals).
|
// Terminal or plain Backspace on other terminals, while also
|
||||||
if (data === "\x08") return true;
|
// overlapping with Ctrl+H.
|
||||||
return matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.ctrl);
|
if (matchesRawBackspace(data, MODIFIERS.ctrl)) return true;
|
||||||
|
return (
|
||||||
|
matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.ctrl) ||
|
||||||
|
matchesModifyOtherKeys(data, CODEPOINTS.backspace, MODIFIERS.ctrl)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (modifier === 0) {
|
if (modifier === 0) {
|
||||||
return data === "\x7f" || matchesKittySequence(data, CODEPOINTS.backspace, 0);
|
return (
|
||||||
|
matchesRawBackspace(data, 0) ||
|
||||||
|
matchesKittySequence(data, CODEPOINTS.backspace, 0) ||
|
||||||
|
matchesModifyOtherKeys(data, CODEPOINTS.backspace, 0)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return matchesKittySequence(data, CODEPOINTS.backspace, modifier);
|
return (
|
||||||
|
matchesKittySequence(data, CODEPOINTS.backspace, modifier) ||
|
||||||
|
matchesModifyOtherKeys(data, CODEPOINTS.backspace, modifier)
|
||||||
|
);
|
||||||
|
|
||||||
case "insert":
|
case "insert":
|
||||||
if (modifier === 0) {
|
if (modifier === 0) {
|
||||||
@@ -1172,7 +1218,7 @@ export function parseKey(data: string): string | undefined {
|
|||||||
if (data === "\x00") return "ctrl+space";
|
if (data === "\x00") return "ctrl+space";
|
||||||
if (data === " ") return "space";
|
if (data === " ") return "space";
|
||||||
if (data === "\x7f") return "backspace";
|
if (data === "\x7f") return "backspace";
|
||||||
if (data === "\x08") return "ctrl+backspace";
|
if (data === "\x08") return isWindowsTerminalSession() ? "ctrl+backspace" : "backspace";
|
||||||
if (data === "\x1b[Z") return "shift+tab";
|
if (data === "\x1b[Z") return "shift+tab";
|
||||||
if (!_kittyProtocolActive && data === "\x1b\r") return "alt+enter";
|
if (!_kittyProtocolActive && data === "\x1b\r") return "alt+enter";
|
||||||
if (!_kittyProtocolActive && data === "\x1b ") return "alt+space";
|
if (!_kittyProtocolActive && data === "\x1b ") return "alt+space";
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ import assert from "node:assert";
|
|||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { matchesKey, parseKey, setKittyProtocolActive } from "../src/keys.js";
|
import { matchesKey, parseKey, setKittyProtocolActive } from "../src/keys.js";
|
||||||
|
|
||||||
|
function withEnv(name: string, value: string | undefined, fn: () => void): void {
|
||||||
|
const previous = process.env[name];
|
||||||
|
if (value === undefined) delete process.env[name];
|
||||||
|
else process.env[name] = value;
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete process.env[name];
|
||||||
|
else process.env[name] = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("matchesKey", () => {
|
describe("matchesKey", () => {
|
||||||
describe("Kitty protocol with alternate keys (non-Latin layouts)", () => {
|
describe("Kitty protocol with alternate keys (non-Latin layouts)", () => {
|
||||||
// Kitty protocol flag 4 (Report alternate keys) sends:
|
// Kitty protocol flag 4 (Report alternate keys) sends:
|
||||||
@@ -167,6 +179,30 @@ describe("matchesKey", () => {
|
|||||||
assert.strictEqual(parseKey("\x1b[27;3;9~"), "alt+tab");
|
assert.strictEqual(parseKey("\x1b[27;3;9~"), "alt+tab");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should match xterm modifyOtherKeys Backspace variants", () => {
|
||||||
|
setKittyProtocolActive(false);
|
||||||
|
assert.strictEqual(matchesKey("\x1b[27;1;127~", "backspace"), true);
|
||||||
|
assert.strictEqual(matchesKey("\x1b[27;5;127~", "ctrl+backspace"), true);
|
||||||
|
assert.strictEqual(matchesKey("\x1b[27;3;127~", "alt+backspace"), true);
|
||||||
|
assert.strictEqual(parseKey("\x1b[27;1;127~"), "backspace");
|
||||||
|
assert.strictEqual(parseKey("\x1b[27;5;127~"), "ctrl+backspace");
|
||||||
|
assert.strictEqual(parseKey("\x1b[27;3;127~"), "alt+backspace");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should match xterm modifyOtherKeys Escape", () => {
|
||||||
|
setKittyProtocolActive(false);
|
||||||
|
assert.strictEqual(matchesKey("\x1b[27;1;27~", "escape"), true);
|
||||||
|
assert.strictEqual(parseKey("\x1b[27;1;27~"), "escape");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should match xterm modifyOtherKeys Space variants", () => {
|
||||||
|
setKittyProtocolActive(false);
|
||||||
|
assert.strictEqual(matchesKey("\x1b[27;1;32~", "space"), true);
|
||||||
|
assert.strictEqual(matchesKey("\x1b[27;5;32~", "ctrl+space"), true);
|
||||||
|
assert.strictEqual(parseKey("\x1b[27;1;32~"), "space");
|
||||||
|
assert.strictEqual(parseKey("\x1b[27;5;32~"), "ctrl+space");
|
||||||
|
});
|
||||||
|
|
||||||
it("should match xterm modifyOtherKeys symbol combos", () => {
|
it("should match xterm modifyOtherKeys symbol combos", () => {
|
||||||
setKittyProtocolActive(false);
|
setKittyProtocolActive(false);
|
||||||
assert.strictEqual(matchesKey("\x1b[27;5;47~", "ctrl+/"), true);
|
assert.strictEqual(matchesKey("\x1b[27;5;47~", "ctrl+/"), true);
|
||||||
@@ -252,18 +288,27 @@ describe("matchesKey", () => {
|
|||||||
assert.strictEqual(parseKey("\x1b\x1f"), "ctrl+alt+-");
|
assert.strictEqual(parseKey("\x1b\x1f"), "ctrl+alt+-");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should distinguish backspace (0x7f) from ctrl+backspace (0x08)", () => {
|
it("should treat raw 0x08 as plain backspace outside Windows Terminal", () => {
|
||||||
setKittyProtocolActive(false);
|
setKittyProtocolActive(false);
|
||||||
// 0x7f is plain backspace
|
withEnv("WT_SESSION", undefined, () => {
|
||||||
assert.strictEqual(matchesKey("\x7f", "backspace"), true);
|
assert.strictEqual(matchesKey("\x7f", "backspace"), true);
|
||||||
assert.strictEqual(matchesKey("\x7f", "ctrl+backspace"), false);
|
assert.strictEqual(matchesKey("\x7f", "ctrl+backspace"), false);
|
||||||
assert.strictEqual(parseKey("\x7f"), "backspace");
|
assert.strictEqual(parseKey("\x7f"), "backspace");
|
||||||
// 0x08 is ctrl+backspace (Windows Terminal sends this for Ctrl+Backspace)
|
assert.strictEqual(matchesKey("\x08", "backspace"), true);
|
||||||
assert.strictEqual(matchesKey("\x08", "ctrl+backspace"), true);
|
assert.strictEqual(matchesKey("\x08", "ctrl+backspace"), false);
|
||||||
assert.strictEqual(matchesKey("\x08", "backspace"), false);
|
assert.strictEqual(parseKey("\x08"), "backspace");
|
||||||
assert.strictEqual(parseKey("\x08"), "ctrl+backspace");
|
assert.strictEqual(matchesKey("\x08", "ctrl+h"), true);
|
||||||
// 0x08 also matches ctrl+h (same byte, ambiguous in legacy terminals)
|
});
|
||||||
assert.strictEqual(matchesKey("\x08", "ctrl+h"), true);
|
});
|
||||||
|
|
||||||
|
it("should treat raw 0x08 as ctrl+backspace in Windows Terminal", () => {
|
||||||
|
setKittyProtocolActive(false);
|
||||||
|
withEnv("WT_SESSION", "test-session", () => {
|
||||||
|
assert.strictEqual(matchesKey("\x08", "ctrl+backspace"), true);
|
||||||
|
assert.strictEqual(matchesKey("\x08", "backspace"), false);
|
||||||
|
assert.strictEqual(parseKey("\x08"), "ctrl+backspace");
|
||||||
|
assert.strictEqual(matchesKey("\x08", "ctrl+h"), true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should parse legacy alt-prefixed sequences when kitty inactive", () => {
|
it("should parse legacy alt-prefixed sequences when kitty inactive", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user