From fa877de1fb85a854edeb06630411cd2eeec8cb2e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 18 Mar 2026 00:48:03 +0100 Subject: [PATCH] fix(tui): resolve raw backspace ambiguity closes #2293 --- packages/tui/CHANGELOG.md | 4 ++ packages/tui/src/keys.ts | 68 ++++++++++++++++++++++++++++------ packages/tui/test/keys.test.ts | 67 +++++++++++++++++++++++++++------ 3 files changed, 117 insertions(+), 22 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index e48f2e16..9fae0ea7 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [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.58.4] - 2026-03-16 diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index dfd0e9bc..a3dc7842 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -663,6 +663,27 @@ function matchesModifyOtherKeys(data: string, expectedKeycode: number, expectedM 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 // ============================================================================= @@ -751,7 +772,11 @@ export function matchesKey(data: string, keyId: KeyId): boolean { case "escape": case "esc": 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": if (!_kittyProtocolActive) { @@ -763,9 +788,16 @@ export function matchesKey(data: string, keyId: KeyId): boolean { } } 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": if (shift && !ctrl && !alt) { @@ -844,18 +876,32 @@ export function matchesKey(data: string, keyId: KeyId): boolean { if (data === "\x1b\x7f" || data === "\x1b\b") { 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) { - // Legacy: 0x08 (BS) is sent by Windows Terminal for Ctrl+Backspace. - // Also matches Ctrl+H (same byte, ambiguous in legacy terminals). - if (data === "\x08") return true; - return matchesKittySequence(data, CODEPOINTS.backspace, 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. + if (matchesRawBackspace(data, MODIFIERS.ctrl)) return true; + return ( + matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.ctrl) || + matchesModifyOtherKeys(data, CODEPOINTS.backspace, MODIFIERS.ctrl) + ); } 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": if (modifier === 0) { @@ -1172,7 +1218,7 @@ export function parseKey(data: string): string | undefined { if (data === "\x00") return "ctrl+space"; if (data === " ") return "space"; 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 (!_kittyProtocolActive && data === "\x1b\r") return "alt+enter"; if (!_kittyProtocolActive && data === "\x1b ") return "alt+space"; diff --git a/packages/tui/test/keys.test.ts b/packages/tui/test/keys.test.ts index 6a7e53ca..c944132c 100644 --- a/packages/tui/test/keys.test.ts +++ b/packages/tui/test/keys.test.ts @@ -6,6 +6,18 @@ import assert from "node:assert"; import { describe, it } from "node:test"; 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("Kitty protocol with alternate keys (non-Latin layouts)", () => { // Kitty protocol flag 4 (Report alternate keys) sends: @@ -167,6 +179,30 @@ describe("matchesKey", () => { 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", () => { setKittyProtocolActive(false); assert.strictEqual(matchesKey("\x1b[27;5;47~", "ctrl+/"), true); @@ -252,18 +288,27 @@ describe("matchesKey", () => { 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); - // 0x7f is plain backspace - assert.strictEqual(matchesKey("\x7f", "backspace"), true); - assert.strictEqual(matchesKey("\x7f", "ctrl+backspace"), false); - assert.strictEqual(parseKey("\x7f"), "backspace"); - // 0x08 is ctrl+backspace (Windows Terminal sends this for Ctrl+Backspace) - assert.strictEqual(matchesKey("\x08", "ctrl+backspace"), true); - assert.strictEqual(matchesKey("\x08", "backspace"), false); - assert.strictEqual(parseKey("\x08"), "ctrl+backspace"); - // 0x08 also matches ctrl+h (same byte, ambiguous in legacy terminals) - assert.strictEqual(matchesKey("\x08", "ctrl+h"), true); + withEnv("WT_SESSION", undefined, () => { + assert.strictEqual(matchesKey("\x7f", "backspace"), true); + assert.strictEqual(matchesKey("\x7f", "ctrl+backspace"), false); + assert.strictEqual(parseKey("\x7f"), "backspace"); + assert.strictEqual(matchesKey("\x08", "backspace"), true); + assert.strictEqual(matchesKey("\x08", "ctrl+backspace"), false); + assert.strictEqual(parseKey("\x08"), "backspace"); + 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", () => {