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);