From dda720af4163973e1aaa94c649e7fb854fe768f4 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 27 Jul 2026 10:27:47 +0000 Subject: [PATCH] fix(tui): allowed remote raw backspace opt-in Windows Terminal identity is commonly lost across SSH and container hops, leaving only the ambiguous raw 0x08 byte. Added the conservative PI_TUI_RAW_BACKSPACE_IS_CTRL=1 opt-in so those sessions can map it to ctrl+backspace without changing the default for terminals where 0x08 means plain Backspace. Restored the public isWindowsTerminalSession and matchesRawBackspace exports and route the parser wrappers through matchesRawBackspace. Documented the runtime flag and covered local WT, remote opt-in, SSH, and 0x7f behavior. Fixes #6782 --- docs/environment-variables.md | 1 + packages/tui/CHANGELOG.md | 2 +- packages/tui/src/keys.ts | 28 +++++++++++------------ packages/tui/test/keys.test.ts | 42 ++++++++++++++++++++++++++++++---- 4 files changed, 53 insertions(+), 20 deletions(-) diff --git a/docs/environment-variables.md b/docs/environment-variables.md index f5f6536da..7b57d49d6 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -407,6 +407,7 @@ These are read as runtime signals; they are usually set by the terminal/OS rathe | ------------------------- | ------------------------------------------------------------------------------------- | | `PI_NOTIFICATIONS` | `off` / `0` / `false` suppress desktop notifications | | `PI_TUI_WRITE_LOG` | If set, logs TUI writes to file | +| `PI_TUI_RAW_BACKSPACE_IS_CTRL` | If `1`, interprets raw `0x08` as Ctrl+Backspace instead of Backspace; use when SSH/container hops hide a Windows Terminal client | | `PI_HARDWARE_CURSOR` | If `1`, enables hardware cursor mode | | `PI_NO_SYNC_OUTPUT` | If set (any non-empty value), disables DEC 2026 synchronized-output wrappers while keeping TUI autowrap guards | | `PI_NO_DECCARA` | If set (truthy), disables Kitty DECCARA rectangular-SGR background fills (forces padded-string rendering) | diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index f6e1861dd..9ea242df9 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -5,7 +5,7 @@ ### Fixed - Fixed the multi-line prompt editor bypassing the keybindings registry for word/line delete and yank: `ctrl+backspace` (a declared default of `tui.editor.deleteWordBackward`) never fired and `keybindings.yml` remaps of `deleteWordBackward`, `deleteWordForward`, `deleteToLineStart`, `deleteToLineEnd`, `yank`, and `yankPop` were ignored, because those actions were matched with hardcoded chords instead of `keybindings.matches(...)` like cursor motion and the single-line `Input` already do ([#6782](https://github.com/can1357/oh-my-pi/issues/6782)). -- Restored the Windows Terminal raw `0x08` → `ctrl+backspace` disambiguation (`WT_SESSION` set, `SSH_*` unset) in the `matchesKey`/`parseKey` seam, replacing dead helpers that were no longer reachable once matching moved to the native parser ([#6782](https://github.com/can1357/oh-my-pi/issues/6782)). +- Restored the Windows Terminal raw `0x08` → `ctrl+backspace` disambiguation (`WT_SESSION` set, `SSH_*` unset) by routing the exported `matchesRawBackspace` helper through the `matchesKey`/`parseKey` seam. Remote SSH/container sessions where terminal identity is unavailable can opt in with `PI_TUI_RAW_BACKSPACE_IS_CTRL=1` ([#6782](https://github.com/can1357/oh-my-pi/issues/6782)). ## [17.1.4] - 2026-07-26 diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index 1ff5dcfe2..5e20710e0 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -29,23 +29,25 @@ import { // Platform Detection // ============================================================================= -function isWindowsTerminalSession(): boolean { +/** Whether the local process is running directly under Windows Terminal. */ +export function isWindowsTerminalSession(): boolean { return ( Boolean(process.env.WT_SESSION) && !process.env.SSH_CONNECTION && !process.env.SSH_CLIENT && !process.env.SSH_TTY ); } /** - * Windows Terminal encodes Ctrl+Backspace as the raw `0x08` (BS) byte and plain - * Backspace as `0x7f` (DEL), unlike terminals that send an explicit CSI-u / - * modifyOtherKeys sequence. The native parser has no environment access and - * reports both bytes as `backspace`, so the ambiguous `0x08` is remapped to - * `ctrl+backspace` here — the one layer where `WT_SESSION` is observable. - * Returns `undefined` for every other input so explicit encodings are untouched. + * Match ambiguous legacy Backspace bytes against an expected modifier mask. + * + * Windows Terminal encodes Ctrl+Backspace as raw `0x08` (BS) and plain + * Backspace as `0x7f` (DEL). Remote/container sessions lose terminal identity, + * so `PI_TUI_RAW_BACKSPACE_IS_CTRL=1` explicitly opts into the same mapping. */ -function windowsTerminalBackspaceOverride(data: string): KeyId | undefined { - if (data !== "\x08") return undefined; - return isWindowsTerminalSession() ? "ctrl+backspace" : undefined; +export function matchesRawBackspace(data: string, expectedModifier: number): boolean { + if (data === "\x7f") return expectedModifier === 0; + if (data !== "\x08") return false; + const rawBackspaceIsCtrl = process.env.PI_TUI_RAW_BACKSPACE_IS_CTRL === "1" || isWindowsTerminalSession(); + return rawBackspaceIsCtrl ? expectedModifier === 4 : expectedModifier === 0; } // ============================================================================= @@ -540,8 +542,7 @@ function matchesKeypadKey(data: string, keyId: KeyId): boolean | undefined { * @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c")) */ export function matchesKey(data: string, keyId: KeyId): boolean { - const wtOverride = windowsTerminalBackspaceOverride(data); - if (wtOverride !== undefined) return wtOverride === keyId; + if (matchesRawBackspace(data, 4)) return keyId === "ctrl+backspace"; return matchesKeypadKey(data, keyId) ?? matchesKeyNative(data, keyId, kittyProtocolActive); } @@ -554,7 +555,6 @@ export function matchesKey(data: string, keyId: KeyId): boolean { * @param data - Raw input data from terminal */ export function parseKey(data: string): string | undefined { - const wtOverride = windowsTerminalBackspaceOverride(data); - if (wtOverride !== undefined) return wtOverride; + if (matchesRawBackspace(data, 4)) return "ctrl+backspace"; return decodeKittyKeypadText(data) ?? parseKeyNative(data, kittyProtocolActive) ?? undefined; } diff --git a/packages/tui/test/keys.test.ts b/packages/tui/test/keys.test.ts index d51fb1b80..708b0e332 100644 --- a/packages/tui/test/keys.test.ts +++ b/packages/tui/test/keys.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "bun:test"; -import { extractPrintableText, matchesKey, parseKey, setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys"; +import { + extractPrintableText, + isWindowsTerminalSession, + matchesKey, + matchesRawBackspace, + parseKey, + setKittyProtocolActive, +} from "@oh-my-pi/pi-tui/keys"; describe("matchesKey", () => { it("matches ctrl+letter sequences", () => { @@ -177,8 +184,8 @@ describe("parseKey", () => { }); }); -describe("Windows Terminal raw 0x08 backspace disambiguation", () => { - const envKeys = ["WT_SESSION", "SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"] as const; +describe("Raw 0x08 backspace disambiguation", () => { + const envKeys = ["WT_SESSION", "SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "PI_TUI_RAW_BACKSPACE_IS_CTRL"] as const; function withEnv(overrides: Partial>, run: () => void): void { const saved: Record = {}; for (const key of envKeys) { @@ -201,12 +208,18 @@ describe("Windows Terminal raw 0x08 backspace disambiguation", () => { // Windows Terminal sends 0x08 for Ctrl+Backspace; the native parser reports // plain `backspace` because it cannot see the environment. withEnv({ WT_SESSION: "1" }, () => { + expect(isWindowsTerminalSession()).toBe(true); + expect(matchesRawBackspace("\x08", 4)).toBe(true); + expect(matchesRawBackspace("\x08", 0)).toBe(false); expect(parseKey("\x08")).toBe("ctrl+backspace"); expect(matchesKey("\x08", "ctrl+backspace")).toBe(true); expect(matchesKey("\x08", "backspace")).toBe(false); }); // Outside Windows Terminal 0x08 stays plain backspace. withEnv({}, () => { + expect(isWindowsTerminalSession()).toBe(false); + expect(matchesRawBackspace("\x08", 0)).toBe(true); + expect(matchesRawBackspace("\x08", 4)).toBe(false); expect(parseKey("\x08")).toBe("backspace"); expect(matchesKey("\x08", "backspace")).toBe(true); expect(matchesKey("\x08", "ctrl+backspace")).toBe(false); @@ -215,12 +228,31 @@ describe("Windows Terminal raw 0x08 backspace disambiguation", () => { it("does not apply the heuristic when WT_SESSION is forwarded over SSH", () => { withEnv({ WT_SESSION: "1", SSH_CONNECTION: "1.2.3.4 5 6.7.8.9 22" }, () => { + expect(isWindowsTerminalSession()).toBe(false); expect(parseKey("\x08")).toBe("backspace"); }); }); - it("leaves 0x7f as plain backspace regardless of Windows Terminal", () => { - withEnv({ WT_SESSION: "1" }, () => { + it("supports an explicit opt-in when remote/container sessions lose terminal identity", () => { + withEnv( + { + PI_TUI_RAW_BACKSPACE_IS_CTRL: "1", + SSH_CONNECTION: "1.2.3.4 5 6.7.8.9 22", + }, + () => { + expect(isWindowsTerminalSession()).toBe(false); + expect(matchesRawBackspace("\x08", 4)).toBe(true); + expect(matchesRawBackspace("\x08", 0)).toBe(false); + expect(parseKey("\x08")).toBe("ctrl+backspace"); + expect(matchesKey("\x08", "ctrl+backspace")).toBe(true); + }, + ); + }); + + it("leaves 0x7f as plain backspace regardless of Windows Terminal or opt-in", () => { + withEnv({ WT_SESSION: "1", PI_TUI_RAW_BACKSPACE_IS_CTRL: "1" }, () => { + expect(matchesRawBackspace("\x7f", 0)).toBe(true); + expect(matchesRawBackspace("\x7f", 4)).toBe(false); expect(parseKey("\x7f")).toBe("backspace"); expect(matchesKey("\x7f", "backspace")).toBe(true); expect(matchesKey("\x7f", "ctrl+backspace")).toBe(false);