From e64b3aa5f1a0cdfb9ab25cd5956ada11ae62df4d Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Mon, 9 Mar 2026 20:58:17 +0100 Subject: [PATCH] fix(core): treat raw \b as Ctrl+Backspace in Windows Terminal Co-authored-by: khush-ubuntu-WSL --- packages/core/src/lib/KeyHandler.test.ts | 29 ++++++- packages/core/src/lib/KeyHandler.ts | 31 +++++++ packages/core/src/renderer.ts | 13 ++- .../core/src/tests/renderer.input.test.ts | 83 +++++++++++++++++++ 4 files changed, 152 insertions(+), 4 deletions(-) diff --git a/packages/core/src/lib/KeyHandler.test.ts b/packages/core/src/lib/KeyHandler.test.ts index cc2a6af5f..dab33f2f9 100644 --- a/packages/core/src/lib/KeyHandler.test.ts +++ b/packages/core/src/lib/KeyHandler.test.ts @@ -1,12 +1,12 @@ import { test, expect } from "bun:test" -import { InternalKeyHandler, KeyEvent } from "./KeyHandler.js" +import { InternalKeyHandler, KeyEvent, type KeyHandlerOptions } from "./KeyHandler.js" import { type ParseKeypressOptions, parseKeypress } from "./parse.keypress.js" import { createTestRenderer } from "../testing/test-renderer.js" const { renderer, mockInput } = await createTestRenderer({}) -function createKeyHandler(): InternalKeyHandler { - return new InternalKeyHandler() +function createKeyHandler(options: KeyHandlerOptions = {}): InternalKeyHandler { + return new InternalKeyHandler(options) } function dispatchInput(handler: InternalKeyHandler, data: string, options: ParseKeypressOptions = {}): boolean { @@ -41,6 +41,29 @@ test("KeyHandler - parsed input emits keypress events", () => { }) }) +test("KeyHandler - can normalize raw backspace to ctrl+backspace", () => { + const handler = createKeyHandler({ treatRawBackspaceAsCtrlBackspace: () => true }) + + let receivedKey: KeyEvent | undefined + handler.on("keypress", (key: KeyEvent) => { + receivedKey = key + }) + + dispatchInput(handler, "\b") + + expect(receivedKey).toMatchObject({ + name: "backspace", + ctrl: true, + meta: false, + shift: false, + option: false, + number: false, + sequence: "\b", + raw: "\b", + eventType: "press", + }) +}) + test("KeyHandler - emits keypress events", () => { const handler = createKeyHandler() diff --git a/packages/core/src/lib/KeyHandler.ts b/packages/core/src/lib/KeyHandler.ts index 438d9a2d4..c2abdfdf0 100644 --- a/packages/core/src/lib/KeyHandler.ts +++ b/packages/core/src/lib/KeyHandler.ts @@ -92,9 +92,19 @@ export type KeyHandlerEventMap = { paste: [PasteEvent] } +export interface KeyHandlerOptions { + treatRawBackspaceAsCtrlBackspace?: boolean | (() => boolean) +} + export class KeyHandler extends EventEmitter { + constructor(private readonly options: KeyHandlerOptions = {}) { + super() + } + public processParsedKey(parsedKey: ParsedKey): boolean { try { + parsedKey = this.normalizeParsedKey(parsedKey) + switch (parsedKey.eventType) { case "press": this.emit("keypress", new KeyEvent(parsedKey)) @@ -114,6 +124,27 @@ export class KeyHandler extends EventEmitter { return true } + private normalizeParsedKey(parsedKey: ParsedKey): ParsedKey { + if ( + parsedKey.raw === "\b" && + parsedKey.name === "backspace" && + !parsedKey.ctrl && + this.shouldTreatRawBackspaceAsCtrlBackspace() + ) { + return { + ...parsedKey, + ctrl: true, + } + } + + return parsedKey + } + + private shouldTreatRawBackspaceAsCtrlBackspace(): boolean { + const value = this.options.treatRawBackspaceAsCtrlBackspace + return typeof value === "function" ? value() : !!value + } + public processPaste(data: string): void { try { const cleanedData = Bun.stripANSI(data) diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 253a3250f..a3176e71a 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -615,7 +615,9 @@ export class CliRenderer extends EventEmitter implements RenderContext { const kittyConfig = config.useKittyKeyboard ?? {} const useKittyForParsing = kittyConfig !== null - this._keyHandler = new InternalKeyHandler() + this._keyHandler = new InternalKeyHandler({ + treatRawBackspaceAsCtrlBackspace: () => this.isWindowsTerminalSession(), + }) this._keyHandler.on("keypress", (event) => { if (this.exitOnCtrlC && event.name === "c" && event.ctrl) { process.nextTick(() => { @@ -1214,6 +1216,15 @@ export class CliRenderer extends EventEmitter implements RenderContext { } } + private isWindowsTerminalSession(): boolean { + if (process.env.WT_SESSION) { + return true + } + + const terminalName = this._capabilities?.terminal?.name?.toLowerCase() + return terminalName === "windows terminal" || terminalName === "windows_terminal" + } + private handleStdinParserFailure(error: unknown): void { if (!this.hasLoggedStdinParserError) { this.hasLoggedStdinParserError = true diff --git a/packages/core/src/tests/renderer.input.test.ts b/packages/core/src/tests/renderer.input.test.ts index cfaf6804d..21979fe39 100644 --- a/packages/core/src/tests/renderer.input.test.ts +++ b/packages/core/src/tests/renderer.input.test.ts @@ -241,6 +241,89 @@ test("special keys via keyInput events", async () => { }) }) +test("raw \\b becomes ctrl+backspace in Windows Terminal", async () => { + const previousWtSession = process.env.WT_SESSION + process.env.WT_SESSION = "test-session" + + try { + const resultBackspace = await triggerInput("\b") + expect(resultBackspace).toMatchObject({ + eventType: "press", + name: "backspace", + ctrl: true, + meta: false, + shift: false, + option: false, + number: false, + sequence: "\b", + raw: "\b", + }) + } finally { + if (previousWtSession === undefined) { + delete process.env.WT_SESSION + } else { + process.env.WT_SESSION = previousWtSession + } + } +}) + +test("raw DEL stays plain backspace in Windows Terminal", async () => { + const previousWtSession = process.env.WT_SESSION + process.env.WT_SESSION = "test-session" + + try { + const resultBackspace = await triggerInput("\x7f") + expect(resultBackspace).toMatchObject({ + eventType: "press", + name: "backspace", + ctrl: false, + meta: false, + shift: false, + option: false, + number: false, + sequence: "\x7f", + raw: "\x7f", + }) + } finally { + if (previousWtSession === undefined) { + delete process.env.WT_SESSION + } else { + process.env.WT_SESSION = previousWtSession + } + } +}) + +test("raw \\b becomes ctrl+backspace from Windows Terminal capability fallback", async () => { + const previousWtSession = process.env.WT_SESSION + delete process.env.WT_SESSION + + const rendererWithPrivateState = currentRenderer as any + const previousCaps = rendererWithPrivateState._capabilities + rendererWithPrivateState._capabilities = { terminal: { name: "Windows Terminal" } } + + try { + const resultBackspace = await triggerInput("\b") + expect(resultBackspace).toMatchObject({ + eventType: "press", + name: "backspace", + ctrl: true, + meta: false, + shift: false, + option: false, + number: false, + sequence: "\b", + raw: "\b", + }) + } finally { + rendererWithPrivateState._capabilities = previousCaps + if (previousWtSession === undefined) { + delete process.env.WT_SESSION + } else { + process.env.WT_SESSION = previousWtSession + } + } +}) + test("ctrl+letter combinations via keyInput events", async () => { const resultCtrlA = await triggerInput("\x01") expect(resultCtrlA).toMatchObject({